mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	feat(server): support for read-only assets and importing existing items in the filesystem (#2715)
* Added read-only flag for assets, endpoint to trigger file import vs upload * updated fixtures with new property * if upload is 'read-only', ensure there is no existing asset at the designated originalPath * added test for file import as well as detecting existing image at read-only destination location * Added storage service test for a case where it should not move read-only assets * upload doesn't need the read-only flag available, just importing * default isReadOnly on import endpoint to true * formatting fixes * create-asset dto needs isReadOnly, so set it to false by default on create, updated api generation * updated code to reflect changes in MR * fixed read stream promise return type * new index for originalPath, check for existing path on import, reglardless of user, to prevent duplicates * refactor: import asset * chore: open api * chore: tests * Added externalPath support for individual users, updated UI to allow this to be set by admin * added missing var for externalPath in ui * chore: open api * fix: compilation issues * fix: server test * built api, fixed user-response dto to include externalPath * reverted accidental commit * bad commit of duplicate externalPath in user response dto * fixed tests to include externalPath on expected result * fix: unit tests * centralized supported filetypes, perform file type checking of asset and sidecar during file import process * centralized supported filetype check method to keep regex DRY * fixed typo * combined migrations into one * update api * Removed externalPath from shared-link code, added column to admin user page whether external paths / import is enabled or not * update mimetype * Fixed detect correct mimetype * revert asset-upload config * reverted domain.constant * refactor * fix mime-type issue * fix format --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
		@@ -105,6 +105,7 @@ describe('User', () => {
 | 
			
		||||
              updatedAt: expect.anything(),
 | 
			
		||||
              oauthId: '',
 | 
			
		||||
              storageLabel: null,
 | 
			
		||||
              externalPath: null,
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
              email: userTwoEmail,
 | 
			
		||||
@@ -119,6 +120,7 @@ describe('User', () => {
 | 
			
		||||
              updatedAt: expect.anything(),
 | 
			
		||||
              oauthId: '',
 | 
			
		||||
              storageLabel: null,
 | 
			
		||||
              externalPath: null,
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
              email: authUserEmail,
 | 
			
		||||
@@ -133,6 +135,7 @@ describe('User', () => {
 | 
			
		||||
              updatedAt: expect.anything(),
 | 
			
		||||
              oauthId: '',
 | 
			
		||||
              storageLabel: 'admin',
 | 
			
		||||
              externalPath: null,
 | 
			
		||||
            },
 | 
			
		||||
          ]),
 | 
			
		||||
        );
 | 
			
		||||
 
 | 
			
		||||
@@ -1430,6 +1430,48 @@
 | 
			
		||||
        ]
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "/asset/import": {
 | 
			
		||||
      "post": {
 | 
			
		||||
        "operationId": "importFile",
 | 
			
		||||
        "parameters": [],
 | 
			
		||||
        "requestBody": {
 | 
			
		||||
          "required": true,
 | 
			
		||||
          "content": {
 | 
			
		||||
            "application/json": {
 | 
			
		||||
              "schema": {
 | 
			
		||||
                "$ref": "#/components/schemas/ImportAssetDto"
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "responses": {
 | 
			
		||||
          "201": {
 | 
			
		||||
            "description": "",
 | 
			
		||||
            "content": {
 | 
			
		||||
              "application/json": {
 | 
			
		||||
                "schema": {
 | 
			
		||||
                  "$ref": "#/components/schemas/AssetFileUploadResponseDto"
 | 
			
		||||
                }
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "tags": [
 | 
			
		||||
          "Asset"
 | 
			
		||||
        ],
 | 
			
		||||
        "security": [
 | 
			
		||||
          {
 | 
			
		||||
            "bearer": []
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            "cookie": []
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            "api_key": []
 | 
			
		||||
          }
 | 
			
		||||
        ]
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "/asset/map-marker": {
 | 
			
		||||
      "get": {
 | 
			
		||||
        "operationId": "getMapMarkers",
 | 
			
		||||
@@ -5085,6 +5127,13 @@
 | 
			
		||||
            "type": "string",
 | 
			
		||||
            "format": "binary"
 | 
			
		||||
          },
 | 
			
		||||
          "isReadOnly": {
 | 
			
		||||
            "type": "boolean",
 | 
			
		||||
            "default": false
 | 
			
		||||
          },
 | 
			
		||||
          "fileExtension": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "deviceAssetId": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
@@ -5108,9 +5157,6 @@
 | 
			
		||||
          "isVisible": {
 | 
			
		||||
            "type": "boolean"
 | 
			
		||||
          },
 | 
			
		||||
          "fileExtension": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "duration": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          }
 | 
			
		||||
@@ -5118,12 +5164,12 @@
 | 
			
		||||
        "required": [
 | 
			
		||||
          "assetType",
 | 
			
		||||
          "assetData",
 | 
			
		||||
          "fileExtension",
 | 
			
		||||
          "deviceAssetId",
 | 
			
		||||
          "deviceId",
 | 
			
		||||
          "fileCreatedAt",
 | 
			
		||||
          "fileModifiedAt",
 | 
			
		||||
          "isFavorite",
 | 
			
		||||
          "fileExtension"
 | 
			
		||||
          "isFavorite"
 | 
			
		||||
        ]
 | 
			
		||||
      },
 | 
			
		||||
      "CreateProfileImageDto": {
 | 
			
		||||
@@ -5186,6 +5232,10 @@
 | 
			
		||||
          "storageLabel": {
 | 
			
		||||
            "type": "string",
 | 
			
		||||
            "nullable": true
 | 
			
		||||
          },
 | 
			
		||||
          "externalPath": {
 | 
			
		||||
            "type": "string",
 | 
			
		||||
            "nullable": true
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "required": [
 | 
			
		||||
@@ -5461,6 +5511,59 @@
 | 
			
		||||
          "timeGroup"
 | 
			
		||||
        ]
 | 
			
		||||
      },
 | 
			
		||||
      "ImportAssetDto": {
 | 
			
		||||
        "type": "object",
 | 
			
		||||
        "properties": {
 | 
			
		||||
          "assetType": {
 | 
			
		||||
            "$ref": "#/components/schemas/AssetTypeEnum"
 | 
			
		||||
          },
 | 
			
		||||
          "isReadOnly": {
 | 
			
		||||
            "type": "boolean",
 | 
			
		||||
            "default": true
 | 
			
		||||
          },
 | 
			
		||||
          "assetPath": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "sidecarPath": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "deviceAssetId": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "deviceId": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "fileCreatedAt": {
 | 
			
		||||
            "format": "date-time",
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "fileModifiedAt": {
 | 
			
		||||
            "format": "date-time",
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "isFavorite": {
 | 
			
		||||
            "type": "boolean"
 | 
			
		||||
          },
 | 
			
		||||
          "isArchived": {
 | 
			
		||||
            "type": "boolean"
 | 
			
		||||
          },
 | 
			
		||||
          "isVisible": {
 | 
			
		||||
            "type": "boolean"
 | 
			
		||||
          },
 | 
			
		||||
          "duration": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "required": [
 | 
			
		||||
          "assetType",
 | 
			
		||||
          "assetPath",
 | 
			
		||||
          "deviceAssetId",
 | 
			
		||||
          "deviceId",
 | 
			
		||||
          "fileCreatedAt",
 | 
			
		||||
          "fileModifiedAt",
 | 
			
		||||
          "isFavorite"
 | 
			
		||||
        ]
 | 
			
		||||
      },
 | 
			
		||||
      "JobCommand": {
 | 
			
		||||
        "type": "string",
 | 
			
		||||
        "enum": [
 | 
			
		||||
@@ -6592,6 +6695,9 @@
 | 
			
		||||
          "storageLabel": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "externalPath": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "isAdmin": {
 | 
			
		||||
            "type": "boolean"
 | 
			
		||||
          },
 | 
			
		||||
@@ -6665,6 +6771,10 @@
 | 
			
		||||
            "type": "string",
 | 
			
		||||
            "nullable": true
 | 
			
		||||
          },
 | 
			
		||||
          "externalPath": {
 | 
			
		||||
            "type": "string",
 | 
			
		||||
            "nullable": true
 | 
			
		||||
          },
 | 
			
		||||
          "profileImagePath": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
@@ -6697,6 +6807,7 @@
 | 
			
		||||
          "firstName",
 | 
			
		||||
          "lastName",
 | 
			
		||||
          "storageLabel",
 | 
			
		||||
          "externalPath",
 | 
			
		||||
          "profileImagePath",
 | 
			
		||||
          "shouldChangePassword",
 | 
			
		||||
          "isAdmin",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										12
									
								
								server/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										12
									
								
								server/package-lock.json
									
									
									
										generated
									
									
									
								
							@@ -21,6 +21,7 @@
 | 
			
		||||
        "@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",
 | 
			
		||||
@@ -38,6 +39,7 @@
 | 
			
		||||
        "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",
 | 
			
		||||
@@ -3018,6 +3020,11 @@
 | 
			
		||||
      "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",
 | 
			
		||||
@@ -14296,6 +14303,11 @@
 | 
			
		||||
      "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",
 | 
			
		||||
 
 | 
			
		||||
@@ -50,6 +50,7 @@
 | 
			
		||||
    "@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",
 | 
			
		||||
@@ -67,6 +68,7 @@
 | 
			
		||||
    "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",
 | 
			
		||||
 
 | 
			
		||||
@@ -169,6 +169,7 @@ describe(AlbumService.name, () => {
 | 
			
		||||
          createdAt: new Date('2021-01-01'),
 | 
			
		||||
          deletedAt: null,
 | 
			
		||||
          updatedAt: new Date('2021-01-01'),
 | 
			
		||||
          externalPath: null,
 | 
			
		||||
        },
 | 
			
		||||
        ownerId: 'admin_id',
 | 
			
		||||
        shared: false,
 | 
			
		||||
 
 | 
			
		||||
@@ -19,6 +19,7 @@ export class APIKeyCore {
 | 
			
		||||
        isAdmin: user.isAdmin,
 | 
			
		||||
        isPublicUser: false,
 | 
			
		||||
        isAllowUpload: true,
 | 
			
		||||
        externalPath: user.externalPath,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -8,4 +8,5 @@ export class AuthUserDto {
 | 
			
		||||
  isAllowDownload?: boolean;
 | 
			
		||||
  isShowExif?: boolean;
 | 
			
		||||
  accessTokenId?: string;
 | 
			
		||||
  externalPath?: string | null;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -2,6 +2,7 @@ export const ICryptoRepository = 'ICryptoRepository';
 | 
			
		||||
 | 
			
		||||
export interface ICryptoRepository {
 | 
			
		||||
  randomBytes(size: number): Buffer;
 | 
			
		||||
  hashFile(filePath: string): Promise<Buffer>;
 | 
			
		||||
  hashSha256(data: string): string;
 | 
			
		||||
  hashBcrypt(data: string | Buffer, saltOrRounds: string | number): Promise<string>;
 | 
			
		||||
  compareBcrypt(data: string | Buffer, encrypted: string): boolean;
 | 
			
		||||
 
 | 
			
		||||
@@ -27,3 +27,60 @@ export function assertMachineLearningEnabled() {
 | 
			
		||||
    throw new BadRequestException('Machine learning is not enabled.');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const validMimeTypes = [
 | 
			
		||||
  'image/avif',
 | 
			
		||||
  'image/gif',
 | 
			
		||||
  'image/heic',
 | 
			
		||||
  'image/heif',
 | 
			
		||||
  'image/jpeg',
 | 
			
		||||
  'image/jxl',
 | 
			
		||||
  'image/png',
 | 
			
		||||
  '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',
 | 
			
		||||
  'video/3gpp',
 | 
			
		||||
  'video/mp2t',
 | 
			
		||||
  'video/mp4',
 | 
			
		||||
  'video/mpeg',
 | 
			
		||||
  'video/quicktime',
 | 
			
		||||
  'video/webm',
 | 
			
		||||
  'video/x-flv',
 | 
			
		||||
  'video/x-matroska',
 | 
			
		||||
  'video/x-ms-wmv',
 | 
			
		||||
  'video/x-msvideo',
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
export function isSupportedFileType(mimetype: string): boolean {
 | 
			
		||||
  return validMimeTypes.includes(mimetype);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isSidecarFileType(mimeType: string): boolean {
 | 
			
		||||
  return ['application/xml', 'text/xml'].includes(mimeType);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -17,6 +17,7 @@ const responseDto = {
 | 
			
		||||
    createdAt: new Date('2021-01-01'),
 | 
			
		||||
    deletedAt: null,
 | 
			
		||||
    updatedAt: new Date('2021-01-01'),
 | 
			
		||||
    externalPath: null,
 | 
			
		||||
  },
 | 
			
		||||
  user1: {
 | 
			
		||||
    email: 'immich@test.com',
 | 
			
		||||
@@ -31,6 +32,7 @@ const responseDto = {
 | 
			
		||||
    createdAt: new Date('2021-01-01'),
 | 
			
		||||
    deletedAt: null,
 | 
			
		||||
    updatedAt: new Date('2021-01-01'),
 | 
			
		||||
    externalPath: null,
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -194,5 +194,26 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
        ['upload/library/user-id/2023/2023-02-23/asset-id.ext', '/original/path.ext'],
 | 
			
		||||
      ]);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should not move read-only asset', async () => {
 | 
			
		||||
      assetMock.getAll.mockResolvedValue({
 | 
			
		||||
        items: [
 | 
			
		||||
          {
 | 
			
		||||
            ...assetEntityStub.image,
 | 
			
		||||
            originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext',
 | 
			
		||||
            isReadOnly: true,
 | 
			
		||||
          },
 | 
			
		||||
        ],
 | 
			
		||||
        hasNextPage: false,
 | 
			
		||||
      });
 | 
			
		||||
      assetMock.save.mockResolvedValue(assetEntityStub.image);
 | 
			
		||||
      userMock.getList.mockResolvedValue([userEntityStub.user1]);
 | 
			
		||||
 | 
			
		||||
      await sut.handleMigration();
 | 
			
		||||
 | 
			
		||||
      expect(assetMock.getAll).toHaveBeenCalled();
 | 
			
		||||
      expect(storageMock.moveFile).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -76,6 +76,11 @@ export class StorageTemplateService {
 | 
			
		||||
 | 
			
		||||
  // TODO: use asset core (once in domain)
 | 
			
		||||
  async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) {
 | 
			
		||||
    if (asset.isReadOnly) {
 | 
			
		||||
      this.logger.verbose(`Not moving read-only asset: ${asset.originalPath}`);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const destination = await this.core.getTemplatePath(asset, metadata);
 | 
			
		||||
    if (asset.originalPath !== destination) {
 | 
			
		||||
      const source = asset.originalPath;
 | 
			
		||||
 
 | 
			
		||||
@@ -23,6 +23,10 @@ export class CreateUserDto {
 | 
			
		||||
  @IsString()
 | 
			
		||||
  @Transform(toSanitized)
 | 
			
		||||
  storageLabel?: string | null;
 | 
			
		||||
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @IsString()
 | 
			
		||||
  externalPath?: string | null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class CreateAdminDto {
 | 
			
		||||
 
 | 
			
		||||
@@ -29,6 +29,10 @@ export class UpdateUserDto {
 | 
			
		||||
  @Transform(toSanitized)
 | 
			
		||||
  storageLabel?: string;
 | 
			
		||||
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @IsString()
 | 
			
		||||
  externalPath?: string;
 | 
			
		||||
 | 
			
		||||
  @IsNotEmpty()
 | 
			
		||||
  @IsUUID('4')
 | 
			
		||||
  @ApiProperty({ format: 'uuid' })
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,7 @@ export class UserResponseDto {
 | 
			
		||||
  firstName!: string;
 | 
			
		||||
  lastName!: string;
 | 
			
		||||
  storageLabel!: string | null;
 | 
			
		||||
  externalPath!: string | null;
 | 
			
		||||
  profileImagePath!: string;
 | 
			
		||||
  shouldChangePassword!: boolean;
 | 
			
		||||
  isAdmin!: boolean;
 | 
			
		||||
@@ -22,6 +23,7 @@ export function mapUser(entity: UserEntity): UserResponseDto {
 | 
			
		||||
    firstName: entity.firstName,
 | 
			
		||||
    lastName: entity.lastName,
 | 
			
		||||
    storageLabel: entity.storageLabel,
 | 
			
		||||
    externalPath: entity.externalPath,
 | 
			
		||||
    profileImagePath: entity.profileImagePath,
 | 
			
		||||
    shouldChangePassword: entity.shouldChangePassword,
 | 
			
		||||
    isAdmin: entity.isAdmin,
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,6 @@ import {
 | 
			
		||||
  Logger,
 | 
			
		||||
  NotFoundException,
 | 
			
		||||
} from '@nestjs/common';
 | 
			
		||||
import { hash } from 'bcrypt';
 | 
			
		||||
import { constants, createReadStream, ReadStream } from 'fs';
 | 
			
		||||
import fs from 'fs/promises';
 | 
			
		||||
import { AuthUserDto } from '../auth';
 | 
			
		||||
@@ -28,6 +27,7 @@ export class UserCore {
 | 
			
		||||
      // Users can never update the isAdmin property.
 | 
			
		||||
      delete dto.isAdmin;
 | 
			
		||||
      delete dto.storageLabel;
 | 
			
		||||
      delete dto.externalPath;
 | 
			
		||||
    } else if (dto.isAdmin && authUser.id !== id) {
 | 
			
		||||
      // Admin cannot create another admin.
 | 
			
		||||
      throw new BadRequestException('The server already has an admin');
 | 
			
		||||
@@ -56,6 +56,10 @@ export class UserCore {
 | 
			
		||||
        dto.storageLabel = null;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (dto.externalPath === '') {
 | 
			
		||||
        dto.externalPath = null;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return this.userRepository.update(id, dto);
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      Logger.error(e, 'Failed to update user info');
 | 
			
		||||
@@ -79,7 +83,7 @@ export class UserCore {
 | 
			
		||||
    try {
 | 
			
		||||
      const payload: Partial<UserEntity> = { ...createUserDto };
 | 
			
		||||
      if (payload.password) {
 | 
			
		||||
        payload.password = await hash(payload.password, SALT_ROUNDS);
 | 
			
		||||
        payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS);
 | 
			
		||||
      }
 | 
			
		||||
      return this.userRepository.create(payload);
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
 
 | 
			
		||||
@@ -53,6 +53,7 @@ const adminUser: UserEntity = Object.freeze({
 | 
			
		||||
  tags: [],
 | 
			
		||||
  assets: [],
 | 
			
		||||
  storageLabel: 'admin',
 | 
			
		||||
  externalPath: null,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const immichUser: UserEntity = Object.freeze({
 | 
			
		||||
@@ -71,6 +72,7 @@ const immichUser: UserEntity = Object.freeze({
 | 
			
		||||
  tags: [],
 | 
			
		||||
  assets: [],
 | 
			
		||||
  storageLabel: null,
 | 
			
		||||
  externalPath: null,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const updatedImmichUser: UserEntity = Object.freeze({
 | 
			
		||||
@@ -89,6 +91,7 @@ const updatedImmichUser: UserEntity = Object.freeze({
 | 
			
		||||
  tags: [],
 | 
			
		||||
  assets: [],
 | 
			
		||||
  storageLabel: null,
 | 
			
		||||
  externalPath: null,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const adminUserResponse = Object.freeze({
 | 
			
		||||
@@ -104,6 +107,7 @@ const adminUserResponse = Object.freeze({
 | 
			
		||||
  deletedAt: null,
 | 
			
		||||
  updatedAt: new Date('2021-01-01'),
 | 
			
		||||
  storageLabel: 'admin',
 | 
			
		||||
  externalPath: null,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
describe(UserService.name, () => {
 | 
			
		||||
@@ -153,6 +157,7 @@ describe(UserService.name, () => {
 | 
			
		||||
          deletedAt: null,
 | 
			
		||||
          updatedAt: new Date('2021-01-01'),
 | 
			
		||||
          storageLabel: 'admin',
 | 
			
		||||
          externalPath: null,
 | 
			
		||||
        },
 | 
			
		||||
      ]);
 | 
			
		||||
    });
 | 
			
		||||
 
 | 
			
		||||
@@ -32,6 +32,7 @@ describe('Album service', () => {
 | 
			
		||||
    tags: [],
 | 
			
		||||
    assets: [],
 | 
			
		||||
    storageLabel: null,
 | 
			
		||||
    externalPath: null,
 | 
			
		||||
  });
 | 
			
		||||
  const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
 | 
			
		||||
  const sharedAlbumOwnerId = '2222';
 | 
			
		||||
 
 | 
			
		||||
@@ -20,6 +20,10 @@ export interface AssetCheck {
 | 
			
		||||
  checksum: Buffer;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface AssetOwnerCheck extends AssetCheck {
 | 
			
		||||
  ownerId: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IAssetRepository {
 | 
			
		||||
  get(id: string): Promise<AssetEntity | null>;
 | 
			
		||||
  create(
 | 
			
		||||
@@ -39,6 +43,7 @@ export interface IAssetRepository {
 | 
			
		||||
  getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
 | 
			
		||||
  getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise<AssetCheck[]>;
 | 
			
		||||
  getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<string[]>;
 | 
			
		||||
  getByOriginalPath(originalPath: string): Promise<AssetOwnerCheck | null>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const IAssetRepository = 'IAssetRepository';
 | 
			
		||||
@@ -350,4 +355,17 @@ export class AssetRepository implements IAssetRepository {
 | 
			
		||||
 | 
			
		||||
    return assetCountByUserId;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getByOriginalPath(originalPath: string): Promise<AssetOwnerCheck | null> {
 | 
			
		||||
    return this.assetRepository.findOne({
 | 
			
		||||
      select: {
 | 
			
		||||
        id: true,
 | 
			
		||||
        ownerId: true,
 | 
			
		||||
        checksum: true,
 | 
			
		||||
      },
 | 
			
		||||
      where: {
 | 
			
		||||
        originalPath,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -33,7 +33,7 @@ import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
 | 
			
		||||
import { AssetSearchDto } from './dto/asset-search.dto';
 | 
			
		||||
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
 | 
			
		||||
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
 | 
			
		||||
import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto';
 | 
			
		||||
import { CreateAssetDto, ImportAssetDto, mapToUploadFile } from './dto/create-asset.dto';
 | 
			
		||||
import { DeleteAssetDto } from './dto/delete-asset.dto';
 | 
			
		||||
import { DeviceIdDto } from './dto/device-id.dto';
 | 
			
		||||
import { DownloadFilesDto } from './dto/download-files.dto';
 | 
			
		||||
@@ -114,6 +114,20 @@ export class AssetController {
 | 
			
		||||
    return responseDto;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Post('import')
 | 
			
		||||
  async importFile(
 | 
			
		||||
    @AuthUser() authUser: AuthUserDto,
 | 
			
		||||
    @Body(new ValidationPipe()) dto: ImportAssetDto,
 | 
			
		||||
    @Response({ passthrough: true }) res: Res,
 | 
			
		||||
  ): Promise<AssetFileUploadResponseDto> {
 | 
			
		||||
    const responseDto = await this.assetService.importFile(authUser, dto);
 | 
			
		||||
    if (responseDto.duplicate) {
 | 
			
		||||
      res.status(200);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return responseDto;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @SharedLinkRoute()
 | 
			
		||||
  @Get('/download/:id')
 | 
			
		||||
  @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
 | 
			
		||||
 
 | 
			
		||||
@@ -2,17 +2,17 @@ import { AuthUserDto, IJobRepository, JobName } from '@app/domain';
 | 
			
		||||
import { AssetEntity, AssetType, UserEntity } from '@app/infra/entities';
 | 
			
		||||
import { parse } from 'node:path';
 | 
			
		||||
import { IAssetRepository } from './asset-repository';
 | 
			
		||||
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
 | 
			
		||||
import { CreateAssetDto, ImportAssetDto, UploadFile } from './dto/create-asset.dto';
 | 
			
		||||
 | 
			
		||||
export class AssetCore {
 | 
			
		||||
  constructor(private repository: IAssetRepository, private jobRepository: IJobRepository) {}
 | 
			
		||||
 | 
			
		||||
  async create(
 | 
			
		||||
    authUser: AuthUserDto,
 | 
			
		||||
    dto: CreateAssetDto,
 | 
			
		||||
    dto: CreateAssetDto | ImportAssetDto,
 | 
			
		||||
    file: UploadFile,
 | 
			
		||||
    livePhotoAssetId?: string,
 | 
			
		||||
    sidecarFile?: UploadFile,
 | 
			
		||||
    sidecarPath?: string,
 | 
			
		||||
  ): Promise<AssetEntity> {
 | 
			
		||||
    const asset = await this.repository.create({
 | 
			
		||||
      owner: { id: authUser.id } as UserEntity,
 | 
			
		||||
@@ -41,7 +41,8 @@ export class AssetCore {
 | 
			
		||||
      sharedLinks: [],
 | 
			
		||||
      originalFileName: parse(file.originalName).name,
 | 
			
		||||
      faces: [],
 | 
			
		||||
      sidecarPath: sidecarFile?.originalPath || null,
 | 
			
		||||
      sidecarPath: sidecarPath || null,
 | 
			
		||||
      isReadOnly: dto.isReadOnly ?? false,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } });
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { IAccessRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain';
 | 
			
		||||
import { IAccessRepository, ICryptoRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain';
 | 
			
		||||
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
 | 
			
		||||
import { ForbiddenException } from '@nestjs/common';
 | 
			
		||||
import {
 | 
			
		||||
@@ -6,6 +6,7 @@ import {
 | 
			
		||||
  authStub,
 | 
			
		||||
  fileStub,
 | 
			
		||||
  newAccessRepositoryMock,
 | 
			
		||||
  newCryptoRepositoryMock,
 | 
			
		||||
  newJobRepositoryMock,
 | 
			
		||||
  newStorageRepositoryMock,
 | 
			
		||||
} from '@test';
 | 
			
		||||
@@ -121,6 +122,7 @@ describe('AssetService', () => {
 | 
			
		||||
  let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
 | 
			
		||||
  let accessMock: jest.Mocked<IAccessRepository>;
 | 
			
		||||
  let assetRepositoryMock: jest.Mocked<IAssetRepository>;
 | 
			
		||||
  let cryptoMock: jest.Mocked<ICryptoRepository>;
 | 
			
		||||
  let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
 | 
			
		||||
  let jobMock: jest.Mocked<IJobRepository>;
 | 
			
		||||
  let storageMock: jest.Mocked<IStorageRepository>;
 | 
			
		||||
@@ -144,13 +146,17 @@ describe('AssetService', () => {
 | 
			
		||||
      getAssetCountByUserId: jest.fn(),
 | 
			
		||||
      getArchivedAssetCountByUserId: jest.fn(),
 | 
			
		||||
      getExistingAssets: jest.fn(),
 | 
			
		||||
      getByOriginalPath: jest.fn(),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    cryptoMock = newCryptoRepositoryMock();
 | 
			
		||||
 | 
			
		||||
    downloadServiceMock = {
 | 
			
		||||
      downloadArchive: jest.fn(),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    accessMock = newAccessRepositoryMock();
 | 
			
		||||
    cryptoMock = newCryptoRepositoryMock();
 | 
			
		||||
    jobMock = newJobRepositoryMock();
 | 
			
		||||
    storageMock = newStorageRepositoryMock();
 | 
			
		||||
 | 
			
		||||
@@ -158,6 +164,7 @@ describe('AssetService', () => {
 | 
			
		||||
      accessMock,
 | 
			
		||||
      assetRepositoryMock,
 | 
			
		||||
      a,
 | 
			
		||||
      cryptoMock,
 | 
			
		||||
      downloadServiceMock as DownloadService,
 | 
			
		||||
      jobMock,
 | 
			
		||||
      storageMock,
 | 
			
		||||
@@ -439,6 +446,43 @@ describe('AssetService', () => {
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('importFile', () => {
 | 
			
		||||
    it('should handle a file import', async () => {
 | 
			
		||||
      assetRepositoryMock.create.mockResolvedValue(assetEntityStub.image);
 | 
			
		||||
      storageMock.checkFileExists.mockResolvedValue(true);
 | 
			
		||||
 | 
			
		||||
      await expect(
 | 
			
		||||
        sut.importFile(authStub.external1, {
 | 
			
		||||
          ..._getCreateAssetDto(),
 | 
			
		||||
          assetPath: '/data/user1/fake_path/asset_1.jpeg',
 | 
			
		||||
          isReadOnly: true,
 | 
			
		||||
        }),
 | 
			
		||||
      ).resolves.toEqual({ duplicate: false, id: 'asset-id' });
 | 
			
		||||
 | 
			
		||||
      expect(assetRepositoryMock.create).toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should handle a duplicate if originalPath already exists', async () => {
 | 
			
		||||
      const error = new QueryFailedError('', [], '');
 | 
			
		||||
      (error as any).constraint = 'UQ_userid_checksum';
 | 
			
		||||
 | 
			
		||||
      assetRepositoryMock.create.mockRejectedValue(error);
 | 
			
		||||
      assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([assetEntityStub.image]);
 | 
			
		||||
      storageMock.checkFileExists.mockResolvedValue(true);
 | 
			
		||||
      cryptoMock.hashFile.mockResolvedValue(Buffer.from('file hash', 'utf8'));
 | 
			
		||||
 | 
			
		||||
      await expect(
 | 
			
		||||
        sut.importFile(authStub.external1, {
 | 
			
		||||
          ..._getCreateAssetDto(),
 | 
			
		||||
          assetPath: '/data/user1/fake_path/asset_1.jpeg',
 | 
			
		||||
          isReadOnly: true,
 | 
			
		||||
        }),
 | 
			
		||||
      ).resolves.toEqual({ duplicate: true, id: 'asset-id' });
 | 
			
		||||
 | 
			
		||||
      expect(assetRepositoryMock.create).toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('getAssetById', () => {
 | 
			
		||||
    it('should allow owner access', async () => {
 | 
			
		||||
      accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,13 @@
 | 
			
		||||
import {
 | 
			
		||||
  AssetResponseDto,
 | 
			
		||||
  AuthUserDto,
 | 
			
		||||
  getLivePhotoMotionFilename,
 | 
			
		||||
  IAccessRepository,
 | 
			
		||||
  ICryptoRepository,
 | 
			
		||||
  IJobRepository,
 | 
			
		||||
  ImmichReadStream,
 | 
			
		||||
  isSidecarFileType,
 | 
			
		||||
  isSupportedFileType,
 | 
			
		||||
  IStorageRepository,
 | 
			
		||||
  JobName,
 | 
			
		||||
  mapAsset,
 | 
			
		||||
@@ -21,12 +25,14 @@ import {
 | 
			
		||||
  StreamableFile,
 | 
			
		||||
} from '@nestjs/common';
 | 
			
		||||
import { InjectRepository } from '@nestjs/typeorm';
 | 
			
		||||
import { R_OK, W_OK } from 'constants';
 | 
			
		||||
import { Response as Res } from 'express';
 | 
			
		||||
import { constants, createReadStream, stat } from 'fs';
 | 
			
		||||
import { createReadStream, stat } from 'fs';
 | 
			
		||||
import fs from 'fs/promises';
 | 
			
		||||
import mime from 'mime-types';
 | 
			
		||||
import path from 'path';
 | 
			
		||||
import { QueryFailedError, Repository } from 'typeorm';
 | 
			
		||||
import { promisify } from 'util';
 | 
			
		||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
 | 
			
		||||
import { DownloadService } from '../../modules/download/download.service';
 | 
			
		||||
import { IAssetRepository } from './asset-repository';
 | 
			
		||||
import { AssetCore } from './asset.core';
 | 
			
		||||
@@ -34,7 +40,7 @@ import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
 | 
			
		||||
import { AssetSearchDto } from './dto/asset-search.dto';
 | 
			
		||||
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
 | 
			
		||||
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
 | 
			
		||||
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
 | 
			
		||||
import { CreateAssetDto, ImportAssetDto, UploadFile } from './dto/create-asset.dto';
 | 
			
		||||
import { DeleteAssetDto } from './dto/delete-asset.dto';
 | 
			
		||||
import { DownloadFilesDto } from './dto/download-files.dto';
 | 
			
		||||
import { DownloadDto } from './dto/download-library.dto';
 | 
			
		||||
@@ -78,6 +84,7 @@ export class AssetService {
 | 
			
		||||
    @Inject(IAccessRepository) private accessRepository: IAccessRepository,
 | 
			
		||||
    @Inject(IAssetRepository) private _assetRepository: IAssetRepository,
 | 
			
		||||
    @InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
 | 
			
		||||
    @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
 | 
			
		||||
    private downloadService: DownloadService,
 | 
			
		||||
    @Inject(IJobRepository) private jobRepository: IJobRepository,
 | 
			
		||||
    @Inject(IStorageRepository) private storageRepository: IStorageRepository,
 | 
			
		||||
@@ -107,7 +114,7 @@ export class AssetService {
 | 
			
		||||
        livePhotoAsset = await this.assetCore.create(authUser, livePhotoDto, livePhotoFile);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const asset = await this.assetCore.create(authUser, dto, file, livePhotoAsset?.id, sidecarFile);
 | 
			
		||||
      const asset = await this.assetCore.create(authUser, dto, file, livePhotoAsset?.id, sidecarFile?.originalPath);
 | 
			
		||||
 | 
			
		||||
      return { id: asset.id, duplicate: false };
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
@@ -129,6 +136,73 @@ export class AssetService {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async importFile(authUser: AuthUserDto, dto: ImportAssetDto): Promise<AssetFileUploadResponseDto> {
 | 
			
		||||
    dto = {
 | 
			
		||||
      ...dto,
 | 
			
		||||
      assetPath: path.resolve(dto.assetPath),
 | 
			
		||||
      sidecarPath: dto.sidecarPath ? path.resolve(dto.sidecarPath) : undefined,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const assetPathType = mime.lookup(dto.assetPath) as string;
 | 
			
		||||
    if (!isSupportedFileType(assetPathType)) {
 | 
			
		||||
      throw new BadRequestException(`Unsupported file type ${assetPathType}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (dto.sidecarPath) {
 | 
			
		||||
      const sidecarType = mime.lookup(dto.sidecarPath) as string;
 | 
			
		||||
      if (!isSidecarFileType(sidecarType)) {
 | 
			
		||||
        throw new BadRequestException(`Unsupported sidecar file type ${assetPathType}`);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    for (const filepath of [dto.assetPath, dto.sidecarPath]) {
 | 
			
		||||
      if (!filepath) {
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const exists = await this.storageRepository.checkFileExists(filepath, R_OK);
 | 
			
		||||
      if (!exists) {
 | 
			
		||||
        throw new BadRequestException('File does not exist');
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!authUser.externalPath || !dto.assetPath.match(new RegExp(`^${authUser.externalPath}`))) {
 | 
			
		||||
      throw new BadRequestException("File does not exist within user's external path");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const assetFile: UploadFile = {
 | 
			
		||||
      checksum: await this.cryptoRepository.hashFile(dto.assetPath),
 | 
			
		||||
      mimeType: assetPathType,
 | 
			
		||||
      originalPath: dto.assetPath,
 | 
			
		||||
      originalName: path.parse(dto.assetPath).name,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const asset = await this.assetCore.create(authUser, dto, assetFile, undefined, dto.sidecarPath);
 | 
			
		||||
      return { id: asset.id, duplicate: false };
 | 
			
		||||
    } catch (error: QueryFailedError | Error | any) {
 | 
			
		||||
      // handle duplicates with a success response
 | 
			
		||||
      if (error instanceof QueryFailedError && (error as any).constraint === 'UQ_userid_checksum') {
 | 
			
		||||
        const [duplicate] = await this._assetRepository.getAssetsByChecksums(authUser.id, [assetFile.checksum]);
 | 
			
		||||
        return { id: duplicate.id, duplicate: true };
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (error instanceof QueryFailedError && (error as any).constraint === 'UQ_4ed4f8052685ff5b1e7ca1058ba') {
 | 
			
		||||
        const duplicate = await this._assetRepository.getByOriginalPath(dto.assetPath);
 | 
			
		||||
        if (duplicate) {
 | 
			
		||||
          if (duplicate.ownerId === authUser.id) {
 | 
			
		||||
            return { id: duplicate.id, duplicate: true };
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          throw new BadRequestException('Path in use by another user');
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      this.logger.error(`Error importing file ${error}`, error?.stack);
 | 
			
		||||
      throw new BadRequestException(`Error importing file`, `${error}`);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) {
 | 
			
		||||
    return this._assetRepository.getAllByDeviceId(authUser.id, deviceId);
 | 
			
		||||
  }
 | 
			
		||||
@@ -291,7 +365,7 @@ export class AssetService {
 | 
			
		||||
        let videoPath = asset.originalPath;
 | 
			
		||||
        let mimeType = asset.mimeType;
 | 
			
		||||
 | 
			
		||||
        await fs.access(videoPath, constants.R_OK | constants.W_OK);
 | 
			
		||||
        await fs.access(videoPath, R_OK | W_OK);
 | 
			
		||||
 | 
			
		||||
        if (asset.encodedVideoPath) {
 | 
			
		||||
          videoPath = asset.encodedVideoPath == '' ? String(asset.originalPath) : String(asset.encodedVideoPath);
 | 
			
		||||
@@ -373,13 +447,16 @@ export class AssetService {
 | 
			
		||||
        await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [id] } });
 | 
			
		||||
 | 
			
		||||
        result.push({ id, status: DeleteAssetStatusEnum.SUCCESS });
 | 
			
		||||
        deleteQueue.push(
 | 
			
		||||
          asset.originalPath,
 | 
			
		||||
          asset.webpPath,
 | 
			
		||||
          asset.resizePath,
 | 
			
		||||
          asset.encodedVideoPath,
 | 
			
		||||
          asset.sidecarPath,
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        if (!asset.isReadOnly) {
 | 
			
		||||
          deleteQueue.push(
 | 
			
		||||
            asset.originalPath,
 | 
			
		||||
            asset.webpPath,
 | 
			
		||||
            asset.resizePath,
 | 
			
		||||
            asset.encodedVideoPath,
 | 
			
		||||
            asset.sidecarPath,
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // TODO refactor this to use cascades
 | 
			
		||||
        if (asset.livePhotoVideoId && !ids.includes(asset.livePhotoVideoId)) {
 | 
			
		||||
@@ -665,7 +742,7 @@ export class AssetService {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await fs.access(filepath, constants.R_OK);
 | 
			
		||||
    await fs.access(filepath, R_OK);
 | 
			
		||||
 | 
			
		||||
    return new StreamableFile(createReadStream(filepath));
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,11 @@
 | 
			
		||||
import { AssetType } from '@app/infra/entities';
 | 
			
		||||
import { ApiProperty } from '@nestjs/swagger';
 | 
			
		||||
import { IsBoolean, IsEnum, IsNotEmpty, IsOptional } from 'class-validator';
 | 
			
		||||
import { Transform } from 'class-transformer';
 | 
			
		||||
import { IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
 | 
			
		||||
import { ImmichFile } from '../../../config/asset-upload.config';
 | 
			
		||||
import { toSanitized } from '../../../utils/transform.util';
 | 
			
		||||
 | 
			
		||||
export class CreateAssetDto {
 | 
			
		||||
export class CreateAssetBase {
 | 
			
		||||
  @IsNotEmpty()
 | 
			
		||||
  deviceAssetId!: string;
 | 
			
		||||
 | 
			
		||||
@@ -32,11 +34,17 @@ export class CreateAssetDto {
 | 
			
		||||
  @IsBoolean()
 | 
			
		||||
  isVisible?: boolean;
 | 
			
		||||
 | 
			
		||||
  @IsNotEmpty()
 | 
			
		||||
  fileExtension!: string;
 | 
			
		||||
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  duration?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class CreateAssetDto extends CreateAssetBase {
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @IsBoolean()
 | 
			
		||||
  isReadOnly?: boolean = false;
 | 
			
		||||
 | 
			
		||||
  @IsNotEmpty()
 | 
			
		||||
  fileExtension!: string;
 | 
			
		||||
 | 
			
		||||
  // The properties below are added to correctly generate the API docs
 | 
			
		||||
  // and client SDKs. Validation should be handled in the controller.
 | 
			
		||||
@@ -50,6 +58,23 @@ export class CreateAssetDto {
 | 
			
		||||
  sidecarData?: any;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class ImportAssetDto extends CreateAssetBase {
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @IsBoolean()
 | 
			
		||||
  isReadOnly?: boolean = true;
 | 
			
		||||
 | 
			
		||||
  @IsString()
 | 
			
		||||
  @IsNotEmpty()
 | 
			
		||||
  @Transform(toSanitized)
 | 
			
		||||
  assetPath!: string;
 | 
			
		||||
 | 
			
		||||
  @IsString()
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @IsNotEmpty()
 | 
			
		||||
  @Transform(toSanitized)
 | 
			
		||||
  sidecarPath?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface UploadFile {
 | 
			
		||||
  mimeType: string;
 | 
			
		||||
  checksum: Buffer;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import { isSidecarFileType, isSupportedFileType } from '@app/domain';
 | 
			
		||||
import { StorageCore, StorageFolder } from '@app/domain/storage';
 | 
			
		||||
import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common';
 | 
			
		||||
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
 | 
			
		||||
@@ -49,67 +50,18 @@ export const multerUtils = { fileFilter, filename, destination };
 | 
			
		||||
 | 
			
		||||
const logger = new Logger('AssetUploadConfig');
 | 
			
		||||
 | 
			
		||||
const validMimeTypes = [
 | 
			
		||||
  'image/avif',
 | 
			
		||||
  'image/gif',
 | 
			
		||||
  'image/heic',
 | 
			
		||||
  'image/heif',
 | 
			
		||||
  'image/jpeg',
 | 
			
		||||
  'image/jxl',
 | 
			
		||||
  'image/png',
 | 
			
		||||
  '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',
 | 
			
		||||
  'video/3gpp',
 | 
			
		||||
  'video/mp2t',
 | 
			
		||||
  'video/mp4',
 | 
			
		||||
  'video/mpeg',
 | 
			
		||||
  'video/quicktime',
 | 
			
		||||
  'video/webm',
 | 
			
		||||
  'video/x-flv',
 | 
			
		||||
  'video/x-matroska',
 | 
			
		||||
  'video/x-ms-wmv',
 | 
			
		||||
  'video/x-msvideo',
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
function fileFilter(req: AuthRequest, file: any, cb: any) {
 | 
			
		||||
  if (!req.user || (req.user.isPublicUser && !req.user.isAllowUpload)) {
 | 
			
		||||
    return cb(new UnauthorizedException());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (validMimeTypes.includes(file.mimetype)) {
 | 
			
		||||
  if (isSupportedFileType(file.mimetype)) {
 | 
			
		||||
    cb(null, true);
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Additionally support XML but only for sidecar files.
 | 
			
		||||
  if (file.fieldname === 'sidecarData' && ['application/xml', 'text/xml'].includes(file.mimetype)) {
 | 
			
		||||
  if (file.fieldname === 'sidecarData' && isSidecarFileType(file.mimetype)) {
 | 
			
		||||
    return cb(null, true);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -42,7 +42,7 @@ export class AssetEntity {
 | 
			
		||||
  @Column()
 | 
			
		||||
  type!: AssetType;
 | 
			
		||||
 | 
			
		||||
  @Column()
 | 
			
		||||
  @Column({ unique: true })
 | 
			
		||||
  originalPath!: string;
 | 
			
		||||
 | 
			
		||||
  @Column({ type: 'varchar', nullable: true })
 | 
			
		||||
@@ -75,6 +75,9 @@ export class AssetEntity {
 | 
			
		||||
  @Column({ type: 'boolean', default: false })
 | 
			
		||||
  isArchived!: boolean;
 | 
			
		||||
 | 
			
		||||
  @Column({ type: 'boolean', default: false })
 | 
			
		||||
  isReadOnly!: boolean;
 | 
			
		||||
 | 
			
		||||
  @Column({ type: 'varchar', nullable: true })
 | 
			
		||||
  mimeType!: string | null;
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -30,6 +30,9 @@ export class UserEntity {
 | 
			
		||||
  @Column({ type: 'varchar', unique: true, default: null })
 | 
			
		||||
  storageLabel!: string | null;
 | 
			
		||||
 | 
			
		||||
  @Column({ type: 'varchar', default: null })
 | 
			
		||||
  externalPath!: string | null;
 | 
			
		||||
 | 
			
		||||
  @Column({ default: '', select: false })
 | 
			
		||||
  password?: string;
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										18
									
								
								server/src/infra/migrations/1686584273471-ImportAsset.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								server/src/infra/migrations/1686584273471-ImportAsset.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
import { MigrationInterface, QueryRunner } from "typeorm";
 | 
			
		||||
 | 
			
		||||
export class ImportAsset1686584273471 implements MigrationInterface {
 | 
			
		||||
    name = 'ImportAsset1686584273471'
 | 
			
		||||
 | 
			
		||||
    public async up(queryRunner: QueryRunner): Promise<void> {
 | 
			
		||||
        await queryRunner.query(`ALTER TABLE "assets" ADD "isReadOnly" boolean NOT NULL DEFAULT false`);
 | 
			
		||||
        await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "UQ_4ed4f8052685ff5b1e7ca1058ba" UNIQUE ("originalPath")`);
 | 
			
		||||
        await queryRunner.query(`ALTER TABLE "users" ADD "externalPath" character varying`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async down(queryRunner: QueryRunner): Promise<void> {
 | 
			
		||||
        await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "UQ_4ed4f8052685ff5b1e7ca1058ba"`);
 | 
			
		||||
        await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "isReadOnly"`);
 | 
			
		||||
        await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "externalPath"`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -2,6 +2,7 @@ import { ICryptoRepository } from '@app/domain';
 | 
			
		||||
import { Injectable } from '@nestjs/common';
 | 
			
		||||
import { compareSync, hash } from 'bcrypt';
 | 
			
		||||
import { createHash, randomBytes } from 'crypto';
 | 
			
		||||
import { createReadStream } from 'fs';
 | 
			
		||||
 | 
			
		||||
@Injectable()
 | 
			
		||||
export class CryptoRepository implements ICryptoRepository {
 | 
			
		||||
@@ -13,4 +14,14 @@ export class CryptoRepository implements ICryptoRepository {
 | 
			
		||||
  hashSha256(value: string) {
 | 
			
		||||
    return createHash('sha256').update(value).digest('base64');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  hashFile(filepath: string): Promise<Buffer> {
 | 
			
		||||
    return new Promise<Buffer>((resolve, reject) => {
 | 
			
		||||
      const hash = createHash('sha1');
 | 
			
		||||
      const stream = createReadStream(filepath);
 | 
			
		||||
      stream.on('error', (err) => reject(err));
 | 
			
		||||
      stream.on('data', (chunk) => hash.update(chunk));
 | 
			
		||||
      stream.on('end', () => resolve(hash.digest()));
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -50,6 +50,7 @@ export const authStub = {
 | 
			
		||||
    isAdmin: true,
 | 
			
		||||
    isPublicUser: false,
 | 
			
		||||
    isAllowUpload: true,
 | 
			
		||||
    externalPath: null,
 | 
			
		||||
  }),
 | 
			
		||||
  user1: Object.freeze<AuthUserDto>({
 | 
			
		||||
    id: 'user-id',
 | 
			
		||||
@@ -60,6 +61,7 @@ export const authStub = {
 | 
			
		||||
    isAllowDownload: true,
 | 
			
		||||
    isShowExif: true,
 | 
			
		||||
    accessTokenId: 'token-id',
 | 
			
		||||
    externalPath: null,
 | 
			
		||||
  }),
 | 
			
		||||
  user2: Object.freeze<AuthUserDto>({
 | 
			
		||||
    id: 'user-2',
 | 
			
		||||
@@ -70,6 +72,18 @@ export const authStub = {
 | 
			
		||||
    isAllowDownload: true,
 | 
			
		||||
    isShowExif: true,
 | 
			
		||||
    accessTokenId: 'token-id',
 | 
			
		||||
    externalPath: null,
 | 
			
		||||
  }),
 | 
			
		||||
  external1: Object.freeze<AuthUserDto>({
 | 
			
		||||
    id: 'user-id',
 | 
			
		||||
    email: 'immich@test.com',
 | 
			
		||||
    isAdmin: false,
 | 
			
		||||
    isPublicUser: false,
 | 
			
		||||
    isAllowUpload: true,
 | 
			
		||||
    isAllowDownload: true,
 | 
			
		||||
    isShowExif: true,
 | 
			
		||||
    accessTokenId: 'token-id',
 | 
			
		||||
    externalPath: '/data/user1',
 | 
			
		||||
  }),
 | 
			
		||||
  adminSharedLink: Object.freeze<AuthUserDto>({
 | 
			
		||||
    id: 'admin_id',
 | 
			
		||||
@@ -111,6 +125,7 @@ export const userEntityStub = {
 | 
			
		||||
    firstName: 'admin_first_name',
 | 
			
		||||
    lastName: 'admin_last_name',
 | 
			
		||||
    storageLabel: 'admin',
 | 
			
		||||
    externalPath: null,
 | 
			
		||||
    oauthId: '',
 | 
			
		||||
    shouldChangePassword: false,
 | 
			
		||||
    profileImagePath: '',
 | 
			
		||||
@@ -126,6 +141,7 @@ export const userEntityStub = {
 | 
			
		||||
    firstName: 'immich_first_name',
 | 
			
		||||
    lastName: 'immich_last_name',
 | 
			
		||||
    storageLabel: null,
 | 
			
		||||
    externalPath: null,
 | 
			
		||||
    oauthId: '',
 | 
			
		||||
    shouldChangePassword: false,
 | 
			
		||||
    profileImagePath: '',
 | 
			
		||||
@@ -141,6 +157,7 @@ export const userEntityStub = {
 | 
			
		||||
    firstName: 'immich_first_name',
 | 
			
		||||
    lastName: 'immich_last_name',
 | 
			
		||||
    storageLabel: null,
 | 
			
		||||
    externalPath: null,
 | 
			
		||||
    oauthId: '',
 | 
			
		||||
    shouldChangePassword: false,
 | 
			
		||||
    profileImagePath: '',
 | 
			
		||||
@@ -156,6 +173,7 @@ export const userEntityStub = {
 | 
			
		||||
    firstName: 'immich_first_name',
 | 
			
		||||
    lastName: 'immich_last_name',
 | 
			
		||||
    storageLabel: 'label-1',
 | 
			
		||||
    externalPath: null,
 | 
			
		||||
    oauthId: '',
 | 
			
		||||
    shouldChangePassword: false,
 | 
			
		||||
    profileImagePath: '',
 | 
			
		||||
@@ -212,6 +230,7 @@ export const assetEntityStub = {
 | 
			
		||||
    sharedLinks: [],
 | 
			
		||||
    faces: [],
 | 
			
		||||
    sidecarPath: null,
 | 
			
		||||
    isReadOnly: false,
 | 
			
		||||
  }),
 | 
			
		||||
  noWebpPath: Object.freeze<AssetEntity>({
 | 
			
		||||
    id: 'asset-id',
 | 
			
		||||
@@ -242,6 +261,7 @@ export const assetEntityStub = {
 | 
			
		||||
    originalFileName: 'asset-id.ext',
 | 
			
		||||
    faces: [],
 | 
			
		||||
    sidecarPath: null,
 | 
			
		||||
    isReadOnly: false,
 | 
			
		||||
  }),
 | 
			
		||||
  noThumbhash: Object.freeze<AssetEntity>({
 | 
			
		||||
    id: 'asset-id',
 | 
			
		||||
@@ -263,6 +283,7 @@ export const assetEntityStub = {
 | 
			
		||||
    mimeType: null,
 | 
			
		||||
    isFavorite: true,
 | 
			
		||||
    isArchived: false,
 | 
			
		||||
    isReadOnly: false,
 | 
			
		||||
    duration: null,
 | 
			
		||||
    isVisible: true,
 | 
			
		||||
    livePhotoVideo: null,
 | 
			
		||||
@@ -293,6 +314,7 @@ export const assetEntityStub = {
 | 
			
		||||
    mimeType: null,
 | 
			
		||||
    isFavorite: true,
 | 
			
		||||
    isArchived: false,
 | 
			
		||||
    isReadOnly: false,
 | 
			
		||||
    duration: null,
 | 
			
		||||
    isVisible: true,
 | 
			
		||||
    livePhotoVideo: null,
 | 
			
		||||
@@ -324,6 +346,7 @@ export const assetEntityStub = {
 | 
			
		||||
    mimeType: null,
 | 
			
		||||
    isFavorite: true,
 | 
			
		||||
    isArchived: false,
 | 
			
		||||
    isReadOnly: false,
 | 
			
		||||
    duration: null,
 | 
			
		||||
    isVisible: true,
 | 
			
		||||
    livePhotoVideo: null,
 | 
			
		||||
@@ -375,6 +398,7 @@ export const assetEntityStub = {
 | 
			
		||||
    mimeType: null,
 | 
			
		||||
    isFavorite: false,
 | 
			
		||||
    isArchived: false,
 | 
			
		||||
    isReadOnly: false,
 | 
			
		||||
    duration: null,
 | 
			
		||||
    isVisible: true,
 | 
			
		||||
    livePhotoVideo: null,
 | 
			
		||||
@@ -408,6 +432,7 @@ export const assetEntityStub = {
 | 
			
		||||
    mimeType: null,
 | 
			
		||||
    isFavorite: true,
 | 
			
		||||
    isArchived: false,
 | 
			
		||||
    isReadOnly: false,
 | 
			
		||||
    duration: null,
 | 
			
		||||
    isVisible: true,
 | 
			
		||||
    livePhotoVideo: null,
 | 
			
		||||
@@ -865,6 +890,7 @@ export const sharedLinkStub = {
 | 
			
		||||
          updatedAt: today,
 | 
			
		||||
          isFavorite: false,
 | 
			
		||||
          isArchived: false,
 | 
			
		||||
          isReadOnly: false,
 | 
			
		||||
          mimeType: 'image/jpeg',
 | 
			
		||||
          smartInfo: {
 | 
			
		||||
            assetId: 'id_1',
 | 
			
		||||
 
 | 
			
		||||
@@ -6,5 +6,6 @@ export const newCryptoRepositoryMock = (): jest.Mocked<ICryptoRepository> => {
 | 
			
		||||
    compareBcrypt: jest.fn().mockReturnValue(true),
 | 
			
		||||
    hashBcrypt: jest.fn().mockImplementation((input) => Promise.resolve(`${input} (hashed)`)),
 | 
			
		||||
    hashSha256: jest.fn().mockImplementation((input) => `${input} (hashed)`),
 | 
			
		||||
    hashFile: jest.fn().mockImplementation((input) => `${input} (file-hashed)`),
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user