mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 02:39:03 -04:00 
			
		
		
		
	chore(server): Use access core for person permissions (#4138)
* use access core for all person methods * minor fixes, feedback * reorder assignments * remove unnecessary permission requirement * unify naming of tests * reorder variables
This commit is contained in:
		
							parent
							
								
									dda735ec51
								
							
						
					
					
						commit
						1a633f3fca
					
				| @ -23,6 +23,10 @@ export enum Permission { | |||||||
| 
 | 
 | ||||||
|   LIBRARY_READ = 'library.read', |   LIBRARY_READ = 'library.read', | ||||||
|   LIBRARY_DOWNLOAD = 'library.download', |   LIBRARY_DOWNLOAD = 'library.download', | ||||||
|  | 
 | ||||||
|  |   PERSON_READ = 'person.read', | ||||||
|  |   PERSON_WRITE = 'person.write', | ||||||
|  |   PERSON_MERGE = 'person.merge', | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export class AccessCore { | export class AccessCore { | ||||||
| @ -167,6 +171,15 @@ export class AccessCore { | |||||||
|       case Permission.LIBRARY_DOWNLOAD: |       case Permission.LIBRARY_DOWNLOAD: | ||||||
|         return authUser.id === id; |         return authUser.id === id; | ||||||
| 
 | 
 | ||||||
|  |       case Permission.PERSON_READ: | ||||||
|  |         return this.repository.person.hasOwnerAccess(authUser.id, id); | ||||||
|  | 
 | ||||||
|  |       case Permission.PERSON_WRITE: | ||||||
|  |         return this.repository.person.hasOwnerAccess(authUser.id, id); | ||||||
|  | 
 | ||||||
|  |       case Permission.PERSON_MERGE: | ||||||
|  |         return this.repository.person.hasOwnerAccess(authUser.id, id); | ||||||
|  | 
 | ||||||
|       default: |       default: | ||||||
|         return false; |         return false; | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -17,4 +17,8 @@ export interface IAccessRepository { | |||||||
|   library: { |   library: { | ||||||
|     hasPartnerAccess(userId: string, partnerId: string): Promise<boolean>; |     hasPartnerAccess(userId: string, partnerId: string): Promise<boolean>; | ||||||
|   }; |   }; | ||||||
|  | 
 | ||||||
|  |   person: { | ||||||
|  |     hasOwnerAccess(userId: string, personId: string): Promise<boolean>; | ||||||
|  |   }; | ||||||
| } | } | ||||||
|  | |||||||
| @ -17,9 +17,9 @@ export interface IPersonRepository { | |||||||
|   getAllWithoutThumbnail(): Promise<PersonEntity[]>; |   getAllWithoutThumbnail(): Promise<PersonEntity[]>; | ||||||
|   getAllForUser(userId: string, options: PersonSearchOptions): Promise<PersonEntity[]>; |   getAllForUser(userId: string, options: PersonSearchOptions): Promise<PersonEntity[]>; | ||||||
|   getAllWithoutFaces(): Promise<PersonEntity[]>; |   getAllWithoutFaces(): Promise<PersonEntity[]>; | ||||||
|   getById(userId: string, personId: string): Promise<PersonEntity | null>; |   getById(personId: string): Promise<PersonEntity | null>; | ||||||
| 
 | 
 | ||||||
|   getAssets(userId: string, personId: string): Promise<AssetEntity[]>; |   getAssets(personId: string): Promise<AssetEntity[]>; | ||||||
|   prepareReassignFaces(data: UpdateFacesData): Promise<string[]>; |   prepareReassignFaces(data: UpdateFacesData): Promise<string[]>; | ||||||
|   reassignFaces(data: UpdateFacesData): Promise<number>; |   reassignFaces(data: UpdateFacesData): Promise<number>; | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1,8 +1,10 @@ | |||||||
| import { BadRequestException, NotFoundException } from '@nestjs/common'; | import { BadRequestException, NotFoundException } from '@nestjs/common'; | ||||||
| import { | import { | ||||||
|  |   IAccessRepositoryMock, | ||||||
|   assetStub, |   assetStub, | ||||||
|   authStub, |   authStub, | ||||||
|   faceStub, |   faceStub, | ||||||
|  |   newAccessRepositoryMock, | ||||||
|   newJobRepositoryMock, |   newJobRepositoryMock, | ||||||
|   newPersonRepositoryMock, |   newPersonRepositoryMock, | ||||||
|   newStorageRepositoryMock, |   newStorageRepositoryMock, | ||||||
| @ -26,18 +28,20 @@ const responseDto: PersonResponseDto = { | |||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| describe(PersonService.name, () => { | describe(PersonService.name, () => { | ||||||
|   let sut: PersonService; |   let accessMock: IAccessRepositoryMock; | ||||||
|   let personMock: jest.Mocked<IPersonRepository>; |  | ||||||
|   let configMock: jest.Mocked<ISystemConfigRepository>; |   let configMock: jest.Mocked<ISystemConfigRepository>; | ||||||
|   let storageMock: jest.Mocked<IStorageRepository>; |  | ||||||
|   let jobMock: jest.Mocked<IJobRepository>; |   let jobMock: jest.Mocked<IJobRepository>; | ||||||
|  |   let personMock: jest.Mocked<IPersonRepository>; | ||||||
|  |   let storageMock: jest.Mocked<IStorageRepository>; | ||||||
|  |   let sut: PersonService; | ||||||
| 
 | 
 | ||||||
|   beforeEach(async () => { |   beforeEach(async () => { | ||||||
|  |     accessMock = newAccessRepositoryMock(); | ||||||
|     personMock = newPersonRepositoryMock(); |     personMock = newPersonRepositoryMock(); | ||||||
|     storageMock = newStorageRepositoryMock(); |     storageMock = newStorageRepositoryMock(); | ||||||
|     configMock = newSystemConfigRepositoryMock(); |     configMock = newSystemConfigRepositoryMock(); | ||||||
|     jobMock = newJobRepositoryMock(); |     jobMock = newJobRepositoryMock(); | ||||||
|     sut = new PersonService(personMock, configMock, storageMock, jobMock); |     sut = new PersonService(accessMock, personMock, configMock, storageMock, jobMock); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   it('should be defined', () => { |   it('should be defined', () => { | ||||||
| @ -93,74 +97,124 @@ describe(PersonService.name, () => { | |||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   describe('getById', () => { |   describe('getById', () => { | ||||||
|  |     it('should require person.read permission', async () => { | ||||||
|  |       personMock.getById.mockResolvedValue(personStub.withName); | ||||||
|  |       accessMock.person.hasOwnerAccess.mockResolvedValue(false); | ||||||
|  |       await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); | ||||||
|  |       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|     it('should throw a bad request when person is not found', async () => { |     it('should throw a bad request when person is not found', async () => { | ||||||
|       personMock.getById.mockResolvedValue(null); |       personMock.getById.mockResolvedValue(null); | ||||||
|  |       accessMock.person.hasOwnerAccess.mockResolvedValue(true); | ||||||
|       await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); |       await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); | ||||||
|  |       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should get a person by id', async () => { |     it('should get a person by id', async () => { | ||||||
|       personMock.getById.mockResolvedValue(personStub.withName); |       personMock.getById.mockResolvedValue(personStub.withName); | ||||||
|  |       accessMock.person.hasOwnerAccess.mockResolvedValue(true); | ||||||
|       await expect(sut.getById(authStub.admin, 'person-1')).resolves.toEqual(responseDto); |       await expect(sut.getById(authStub.admin, 'person-1')).resolves.toEqual(responseDto); | ||||||
|       expect(personMock.getById).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); |       expect(personMock.getById).toHaveBeenCalledWith('person-1'); | ||||||
|  |       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   describe('getThumbnail', () => { |   describe('getThumbnail', () => { | ||||||
|  |     it('should require person.read permission', async () => { | ||||||
|  |       personMock.getById.mockResolvedValue(personStub.noName); | ||||||
|  |       accessMock.person.hasOwnerAccess.mockResolvedValue(false); | ||||||
|  |       await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); | ||||||
|  |       expect(storageMock.createReadStream).not.toHaveBeenCalled(); | ||||||
|  |       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|     it('should throw an error when personId is invalid', async () => { |     it('should throw an error when personId is invalid', async () => { | ||||||
|       personMock.getById.mockResolvedValue(null); |       personMock.getById.mockResolvedValue(null); | ||||||
|  |       accessMock.person.hasOwnerAccess.mockResolvedValue(true); | ||||||
|       await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException); |       await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException); | ||||||
|       expect(storageMock.createReadStream).not.toHaveBeenCalled(); |       expect(storageMock.createReadStream).not.toHaveBeenCalled(); | ||||||
|  |       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should throw an error when person has no thumbnail', async () => { |     it('should throw an error when person has no thumbnail', async () => { | ||||||
|       personMock.getById.mockResolvedValue(personStub.noThumbnail); |       personMock.getById.mockResolvedValue(personStub.noThumbnail); | ||||||
|  |       accessMock.person.hasOwnerAccess.mockResolvedValue(true); | ||||||
|       await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException); |       await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException); | ||||||
|       expect(storageMock.createReadStream).not.toHaveBeenCalled(); |       expect(storageMock.createReadStream).not.toHaveBeenCalled(); | ||||||
|  |       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should serve the thumbnail', async () => { |     it('should serve the thumbnail', async () => { | ||||||
|       personMock.getById.mockResolvedValue(personStub.noName); |       personMock.getById.mockResolvedValue(personStub.noName); | ||||||
|  |       accessMock.person.hasOwnerAccess.mockResolvedValue(true); | ||||||
|       await sut.getThumbnail(authStub.admin, 'person-1'); |       await sut.getThumbnail(authStub.admin, 'person-1'); | ||||||
|       expect(storageMock.createReadStream).toHaveBeenCalledWith('/path/to/thumbnail.jpg', 'image/jpeg'); |       expect(storageMock.createReadStream).toHaveBeenCalledWith('/path/to/thumbnail.jpg', 'image/jpeg'); | ||||||
|  |       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   describe('getAssets', () => { |   describe('getAssets', () => { | ||||||
|  |     it('should require person.read permission', async () => { | ||||||
|  |       personMock.getAssets.mockResolvedValue([assetStub.image, assetStub.video]); | ||||||
|  |       accessMock.person.hasOwnerAccess.mockResolvedValue(false); | ||||||
|  |       await expect(sut.getAssets(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); | ||||||
|  |       expect(personMock.getAssets).not.toHaveBeenCalled(); | ||||||
|  |       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|     it("should return a person's assets", async () => { |     it("should return a person's assets", async () => { | ||||||
|       personMock.getAssets.mockResolvedValue([assetStub.image, assetStub.video]); |       personMock.getAssets.mockResolvedValue([assetStub.image, assetStub.video]); | ||||||
|  |       accessMock.person.hasOwnerAccess.mockResolvedValue(true); | ||||||
|       await sut.getAssets(authStub.admin, 'person-1'); |       await sut.getAssets(authStub.admin, 'person-1'); | ||||||
|       expect(personMock.getAssets).toHaveBeenCalledWith('admin_id', 'person-1'); |       expect(personMock.getAssets).toHaveBeenCalledWith('person-1'); | ||||||
|  |       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   describe('update', () => { |   describe('update', () => { | ||||||
|     it('should throw an error when personId is invalid', async () => { |     it('should require person.write permission', async () => { | ||||||
|       personMock.getById.mockResolvedValue(null); |       personMock.getById.mockResolvedValue(personStub.noName); | ||||||
|  |       accessMock.person.hasOwnerAccess.mockResolvedValue(false); | ||||||
|       await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf( |       await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf( | ||||||
|         BadRequestException, |         BadRequestException, | ||||||
|       ); |       ); | ||||||
|       expect(personMock.update).not.toHaveBeenCalled(); |       expect(personMock.update).not.toHaveBeenCalled(); | ||||||
|  |       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should throw an error when personId is invalid', async () => { | ||||||
|  |       personMock.getById.mockResolvedValue(null); | ||||||
|  |       accessMock.person.hasOwnerAccess.mockResolvedValue(true); | ||||||
|  |       await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf( | ||||||
|  |         BadRequestException, | ||||||
|  |       ); | ||||||
|  |       expect(personMock.update).not.toHaveBeenCalled(); | ||||||
|  |       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it("should update a person's name", async () => { |     it("should update a person's name", async () => { | ||||||
|       personMock.getById.mockResolvedValue(personStub.noName); |       personMock.getById.mockResolvedValue(personStub.noName); | ||||||
|       personMock.update.mockResolvedValue(personStub.withName); |       personMock.update.mockResolvedValue(personStub.withName); | ||||||
|       personMock.getAssets.mockResolvedValue([assetStub.image]); |       personMock.getAssets.mockResolvedValue([assetStub.image]); | ||||||
|  |       accessMock.person.hasOwnerAccess.mockResolvedValue(true); | ||||||
| 
 | 
 | ||||||
|       await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto); |       await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto); | ||||||
| 
 | 
 | ||||||
|       expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1'); |       expect(personMock.getById).toHaveBeenCalledWith('person-1'); | ||||||
|       expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' }); |       expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' }); | ||||||
|       expect(jobMock.queue).toHaveBeenCalledWith({ |       expect(jobMock.queue).toHaveBeenCalledWith({ | ||||||
|         name: JobName.SEARCH_INDEX_ASSET, |         name: JobName.SEARCH_INDEX_ASSET, | ||||||
|         data: { ids: [assetStub.image.id] }, |         data: { ids: [assetStub.image.id] }, | ||||||
|       }); |       }); | ||||||
|  |       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it("should update a person's date of birth", async () => { |     it("should update a person's date of birth", async () => { | ||||||
|       personMock.getById.mockResolvedValue(personStub.noBirthDate); |       personMock.getById.mockResolvedValue(personStub.noBirthDate); | ||||||
|       personMock.update.mockResolvedValue(personStub.withBirthDate); |       personMock.update.mockResolvedValue(personStub.withBirthDate); | ||||||
|       personMock.getAssets.mockResolvedValue([assetStub.image]); |       personMock.getAssets.mockResolvedValue([assetStub.image]); | ||||||
|  |       accessMock.person.hasOwnerAccess.mockResolvedValue(true); | ||||||
| 
 | 
 | ||||||
|       await expect(sut.update(authStub.admin, 'person-1', { birthDate: new Date('1976-06-30') })).resolves.toEqual({ |       await expect(sut.update(authStub.admin, 'person-1', { birthDate: new Date('1976-06-30') })).resolves.toEqual({ | ||||||
|         id: 'person-1', |         id: 'person-1', | ||||||
| @ -170,35 +224,39 @@ describe(PersonService.name, () => { | |||||||
|         isHidden: false, |         isHidden: false, | ||||||
|       }); |       }); | ||||||
| 
 | 
 | ||||||
|       expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1'); |       expect(personMock.getById).toHaveBeenCalledWith('person-1'); | ||||||
|       expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') }); |       expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') }); | ||||||
|       expect(jobMock.queue).not.toHaveBeenCalled(); |       expect(jobMock.queue).not.toHaveBeenCalled(); | ||||||
|  |       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should update a person visibility', async () => { |     it('should update a person visibility', async () => { | ||||||
|       personMock.getById.mockResolvedValue(personStub.hidden); |       personMock.getById.mockResolvedValue(personStub.hidden); | ||||||
|       personMock.update.mockResolvedValue(personStub.withName); |       personMock.update.mockResolvedValue(personStub.withName); | ||||||
|       personMock.getAssets.mockResolvedValue([assetStub.image]); |       personMock.getAssets.mockResolvedValue([assetStub.image]); | ||||||
|  |       accessMock.person.hasOwnerAccess.mockResolvedValue(true); | ||||||
| 
 | 
 | ||||||
|       await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto); |       await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto); | ||||||
| 
 | 
 | ||||||
|       expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1'); |       expect(personMock.getById).toHaveBeenCalledWith('person-1'); | ||||||
|       expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false }); |       expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false }); | ||||||
|       expect(jobMock.queue).toHaveBeenCalledWith({ |       expect(jobMock.queue).toHaveBeenCalledWith({ | ||||||
|         name: JobName.SEARCH_INDEX_ASSET, |         name: JobName.SEARCH_INDEX_ASSET, | ||||||
|         data: { ids: [assetStub.image.id] }, |         data: { ids: [assetStub.image.id] }, | ||||||
|       }); |       }); | ||||||
|  |       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it("should update a person's thumbnailPath", async () => { |     it("should update a person's thumbnailPath", async () => { | ||||||
|       personMock.getById.mockResolvedValue(personStub.withName); |       personMock.getById.mockResolvedValue(personStub.withName); | ||||||
|       personMock.getFaceById.mockResolvedValue(faceStub.face1); |       personMock.getFaceById.mockResolvedValue(faceStub.face1); | ||||||
|  |       accessMock.person.hasOwnerAccess.mockResolvedValue(true); | ||||||
| 
 | 
 | ||||||
|       await expect( |       await expect( | ||||||
|         sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }), |         sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }), | ||||||
|       ).resolves.toEqual(responseDto); |       ).resolves.toEqual(responseDto); | ||||||
| 
 | 
 | ||||||
|       expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1'); |       expect(personMock.getById).toHaveBeenCalledWith('person-1'); | ||||||
|       expect(personMock.getFaceById).toHaveBeenCalledWith({ |       expect(personMock.getFaceById).toHaveBeenCalledWith({ | ||||||
|         assetId: faceStub.face1.assetId, |         assetId: faceStub.face1.assetId, | ||||||
|         personId: 'person-1', |         personId: 'person-1', | ||||||
| @ -218,25 +276,31 @@ describe(PersonService.name, () => { | |||||||
|           imageWidth: faceStub.face1.imageWidth, |           imageWidth: faceStub.face1.imageWidth, | ||||||
|         }, |         }, | ||||||
|       }); |       }); | ||||||
|  |       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should throw an error when the face feature assetId is invalid', async () => { |     it('should throw an error when the face feature assetId is invalid', async () => { | ||||||
|       personMock.getById.mockResolvedValue(personStub.withName); |       personMock.getById.mockResolvedValue(personStub.withName); | ||||||
|  |       accessMock.person.hasOwnerAccess.mockResolvedValue(true); | ||||||
| 
 | 
 | ||||||
|       await expect(sut.update(authStub.admin, 'person-1', { featureFaceAssetId: '-1' })).rejects.toThrow( |       await expect(sut.update(authStub.admin, 'person-1', { featureFaceAssetId: '-1' })).rejects.toThrow( | ||||||
|         BadRequestException, |         BadRequestException, | ||||||
|       ); |       ); | ||||||
|       expect(personMock.update).not.toHaveBeenCalled(); |       expect(personMock.update).not.toHaveBeenCalled(); | ||||||
|  |       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   describe('updateAll', () => { |   describe('updateAll', () => { | ||||||
|     it('should throw an error when personId is invalid', async () => { |     it('should throw an error when personId is invalid', async () => { | ||||||
|       personMock.getById.mockResolvedValue(null); |       personMock.getById.mockResolvedValue(null); | ||||||
|  |       accessMock.person.hasOwnerAccess.mockResolvedValue(true); | ||||||
|  | 
 | ||||||
|       await expect( |       await expect( | ||||||
|         sut.updatePeople(authStub.admin, { people: [{ id: 'person-1', name: 'Person 1' }] }), |         sut.updatePeople(authStub.admin, { people: [{ id: 'person-1', name: 'Person 1' }] }), | ||||||
|       ).resolves.toEqual([{ error: BulkIdErrorReason.UNKNOWN, id: 'person-1', success: false }]); |       ).resolves.toEqual([{ error: BulkIdErrorReason.UNKNOWN, id: 'person-1', success: false }]); | ||||||
|       expect(personMock.update).not.toHaveBeenCalled(); |       expect(personMock.update).not.toHaveBeenCalled(); | ||||||
|  |       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
| @ -255,11 +319,31 @@ describe(PersonService.name, () => { | |||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   describe('mergePerson', () => { |   describe('mergePerson', () => { | ||||||
|  |     it('should require person.write and person.merge permission', async () => { | ||||||
|  |       personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); | ||||||
|  |       personMock.getById.mockResolvedValueOnce(personStub.mergePerson); | ||||||
|  |       personMock.prepareReassignFaces.mockResolvedValue([]); | ||||||
|  |       personMock.delete.mockResolvedValue(personStub.mergePerson); | ||||||
|  |       accessMock.person.hasOwnerAccess.mockResolvedValue(false); | ||||||
|  | 
 | ||||||
|  |       await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf( | ||||||
|  |         BadRequestException, | ||||||
|  |       ); | ||||||
|  | 
 | ||||||
|  |       expect(personMock.prepareReassignFaces).not.toHaveBeenCalled(); | ||||||
|  | 
 | ||||||
|  |       expect(personMock.reassignFaces).not.toHaveBeenCalled(); | ||||||
|  | 
 | ||||||
|  |       expect(personMock.delete).not.toHaveBeenCalled(); | ||||||
|  |       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|     it('should merge two people', async () => { |     it('should merge two people', async () => { | ||||||
|       personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); |       personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); | ||||||
|       personMock.getById.mockResolvedValueOnce(personStub.mergePerson); |       personMock.getById.mockResolvedValueOnce(personStub.mergePerson); | ||||||
|       personMock.prepareReassignFaces.mockResolvedValue([]); |       personMock.prepareReassignFaces.mockResolvedValue([]); | ||||||
|       personMock.delete.mockResolvedValue(personStub.mergePerson); |       personMock.delete.mockResolvedValue(personStub.mergePerson); | ||||||
|  |       accessMock.person.hasOwnerAccess.mockResolvedValue(true); | ||||||
| 
 | 
 | ||||||
|       await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ |       await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ | ||||||
|         { id: 'person-2', success: true }, |         { id: 'person-2', success: true }, | ||||||
| @ -276,12 +360,14 @@ describe(PersonService.name, () => { | |||||||
|       }); |       }); | ||||||
| 
 | 
 | ||||||
|       expect(personMock.delete).toHaveBeenCalledWith(personStub.mergePerson); |       expect(personMock.delete).toHaveBeenCalledWith(personStub.mergePerson); | ||||||
|  |       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should delete conflicting faces before merging', async () => { |     it('should delete conflicting faces before merging', async () => { | ||||||
|       personMock.getById.mockResolvedValue(personStub.primaryPerson); |       personMock.getById.mockResolvedValue(personStub.primaryPerson); | ||||||
|       personMock.getById.mockResolvedValue(personStub.mergePerson); |       personMock.getById.mockResolvedValue(personStub.mergePerson); | ||||||
|       personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]); |       personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]); | ||||||
|  |       accessMock.person.hasOwnerAccess.mockResolvedValue(true); | ||||||
| 
 | 
 | ||||||
|       await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ |       await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ | ||||||
|         { id: 'person-2', success: true }, |         { id: 'person-2', success: true }, | ||||||
| @ -296,21 +382,25 @@ describe(PersonService.name, () => { | |||||||
|         name: JobName.SEARCH_REMOVE_FACE, |         name: JobName.SEARCH_REMOVE_FACE, | ||||||
|         data: { assetId: assetStub.image.id, personId: personStub.mergePerson.id }, |         data: { assetId: assetStub.image.id, personId: personStub.mergePerson.id }, | ||||||
|       }); |       }); | ||||||
|  |       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should throw an error when the primary person is not found', async () => { |     it('should throw an error when the primary person is not found', async () => { | ||||||
|       personMock.getById.mockResolvedValue(null); |       personMock.getById.mockResolvedValue(null); | ||||||
|  |       accessMock.person.hasOwnerAccess.mockResolvedValue(true); | ||||||
| 
 | 
 | ||||||
|       await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf( |       await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf( | ||||||
|         BadRequestException, |         BadRequestException, | ||||||
|       ); |       ); | ||||||
| 
 | 
 | ||||||
|       expect(personMock.delete).not.toHaveBeenCalled(); |       expect(personMock.delete).not.toHaveBeenCalled(); | ||||||
|  |       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should handle invalid merge ids', async () => { |     it('should handle invalid merge ids', async () => { | ||||||
|       personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); |       personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); | ||||||
|       personMock.getById.mockResolvedValueOnce(null); |       personMock.getById.mockResolvedValueOnce(null); | ||||||
|  |       accessMock.person.hasOwnerAccess.mockResolvedValue(true); | ||||||
| 
 | 
 | ||||||
|       await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ |       await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ | ||||||
|         { id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND }, |         { id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND }, | ||||||
| @ -319,6 +409,7 @@ describe(PersonService.name, () => { | |||||||
|       expect(personMock.prepareReassignFaces).not.toHaveBeenCalled(); |       expect(personMock.prepareReassignFaces).not.toHaveBeenCalled(); | ||||||
|       expect(personMock.reassignFaces).not.toHaveBeenCalled(); |       expect(personMock.reassignFaces).not.toHaveBeenCalled(); | ||||||
|       expect(personMock.delete).not.toHaveBeenCalled(); |       expect(personMock.delete).not.toHaveBeenCalled(); | ||||||
|  |       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should handle an error reassigning faces', async () => { |     it('should handle an error reassigning faces', async () => { | ||||||
| @ -326,12 +417,14 @@ describe(PersonService.name, () => { | |||||||
|       personMock.getById.mockResolvedValue(personStub.mergePerson); |       personMock.getById.mockResolvedValue(personStub.mergePerson); | ||||||
|       personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]); |       personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]); | ||||||
|       personMock.reassignFaces.mockRejectedValue(new Error('update failed')); |       personMock.reassignFaces.mockRejectedValue(new Error('update failed')); | ||||||
|  |       accessMock.person.hasOwnerAccess.mockResolvedValue(true); | ||||||
| 
 | 
 | ||||||
|       await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ |       await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ | ||||||
|         { id: 'person-2', success: false, error: BulkIdErrorReason.UNKNOWN }, |         { id: 'person-2', success: false, error: BulkIdErrorReason.UNKNOWN }, | ||||||
|       ]); |       ]); | ||||||
| 
 | 
 | ||||||
|       expect(personMock.delete).not.toHaveBeenCalled(); |       expect(personMock.delete).not.toHaveBeenCalled(); | ||||||
|  |       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
| }); | }); | ||||||
|  | |||||||
| @ -1,4 +1,5 @@ | |||||||
| import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; | import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; | ||||||
|  | import { AccessCore, IAccessRepository, Permission } from '../access'; | ||||||
| import { AssetResponseDto, BulkIdErrorReason, BulkIdResponseDto, mapAsset } from '../asset'; | import { AssetResponseDto, BulkIdErrorReason, BulkIdResponseDto, mapAsset } from '../asset'; | ||||||
| import { AuthUserDto } from '../auth'; | import { AuthUserDto } from '../auth'; | ||||||
| import { mimeTypes } from '../domain.constant'; | import { mimeTypes } from '../domain.constant'; | ||||||
| @ -18,15 +19,18 @@ import { IPersonRepository, UpdateFacesData } from './person.repository'; | |||||||
| 
 | 
 | ||||||
| @Injectable() | @Injectable() | ||||||
| export class PersonService { | export class PersonService { | ||||||
|  |   private access: AccessCore; | ||||||
|   private configCore: SystemConfigCore; |   private configCore: SystemConfigCore; | ||||||
|   readonly logger = new Logger(PersonService.name); |   readonly logger = new Logger(PersonService.name); | ||||||
| 
 | 
 | ||||||
|   constructor( |   constructor( | ||||||
|  |     @Inject(IAccessRepository) private accessRepository: IAccessRepository, | ||||||
|     @Inject(IPersonRepository) private repository: IPersonRepository, |     @Inject(IPersonRepository) private repository: IPersonRepository, | ||||||
|     @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, |     @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, | ||||||
|     @Inject(IStorageRepository) private storageRepository: IStorageRepository, |     @Inject(IStorageRepository) private storageRepository: IStorageRepository, | ||||||
|     @Inject(IJobRepository) private jobRepository: IJobRepository, |     @Inject(IJobRepository) private jobRepository: IJobRepository, | ||||||
|   ) { |   ) { | ||||||
|  |     this.access = new AccessCore(accessRepository); | ||||||
|     this.configCore = new SystemConfigCore(configRepository); |     this.configCore = new SystemConfigCore(configRepository); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @ -48,12 +52,14 @@ export class PersonService { | |||||||
|     }; |     }; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   getById(authUser: AuthUserDto, id: string): Promise<PersonResponseDto> { |   async getById(authUser: AuthUserDto, id: string): Promise<PersonResponseDto> { | ||||||
|     return this.findOrFail(authUser, id).then(mapPerson); |     await this.access.requirePermission(authUser, Permission.PERSON_READ, id); | ||||||
|  |     return this.findOrFail(id).then(mapPerson); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async getThumbnail(authUser: AuthUserDto, id: string): Promise<ImmichReadStream> { |   async getThumbnail(authUser: AuthUserDto, id: string): Promise<ImmichReadStream> { | ||||||
|     const person = await this.repository.getById(authUser.id, id); |     await this.access.requirePermission(authUser, Permission.PERSON_READ, id); | ||||||
|  |     const person = await this.repository.getById(id); | ||||||
|     if (!person || !person.thumbnailPath) { |     if (!person || !person.thumbnailPath) { | ||||||
|       throw new NotFoundException(); |       throw new NotFoundException(); | ||||||
|     } |     } | ||||||
| @ -62,17 +68,19 @@ export class PersonService { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async getAssets(authUser: AuthUserDto, id: string): Promise<AssetResponseDto[]> { |   async getAssets(authUser: AuthUserDto, id: string): Promise<AssetResponseDto[]> { | ||||||
|     const assets = await this.repository.getAssets(authUser.id, id); |     await this.access.requirePermission(authUser, Permission.PERSON_READ, id); | ||||||
|  |     const assets = await this.repository.getAssets(id); | ||||||
|     return assets.map(mapAsset); |     return assets.map(mapAsset); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> { |   async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> { | ||||||
|     let person = await this.findOrFail(authUser, id); |     await this.access.requirePermission(authUser, Permission.PERSON_WRITE, id); | ||||||
|  |     let person = await this.findOrFail(id); | ||||||
| 
 | 
 | ||||||
|     if (dto.name !== undefined || dto.birthDate !== undefined || dto.isHidden !== undefined) { |     if (dto.name !== undefined || dto.birthDate !== undefined || dto.isHidden !== undefined) { | ||||||
|       person = await this.repository.update({ id, name: dto.name, birthDate: dto.birthDate, isHidden: dto.isHidden }); |       person = await this.repository.update({ id, name: dto.name, birthDate: dto.birthDate, isHidden: dto.isHidden }); | ||||||
|       if (this.needsSearchIndexUpdate(dto)) { |       if (this.needsSearchIndexUpdate(dto)) { | ||||||
|         const assets = await this.repository.getAssets(authUser.id, id); |         const assets = await this.repository.getAssets(id); | ||||||
|         const ids = assets.map((asset) => asset.id); |         const ids = assets.map((asset) => asset.id); | ||||||
|         await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } }); |         await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } }); | ||||||
|       } |       } | ||||||
| @ -141,14 +149,22 @@ export class PersonService { | |||||||
| 
 | 
 | ||||||
|   async mergePerson(authUser: AuthUserDto, id: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> { |   async mergePerson(authUser: AuthUserDto, id: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> { | ||||||
|     const mergeIds = dto.ids; |     const mergeIds = dto.ids; | ||||||
|     const primaryPerson = await this.findOrFail(authUser, id); |     await this.access.requirePermission(authUser, Permission.PERSON_WRITE, id); | ||||||
|  |     const primaryPerson = await this.findOrFail(id); | ||||||
|     const primaryName = primaryPerson.name || primaryPerson.id; |     const primaryName = primaryPerson.name || primaryPerson.id; | ||||||
| 
 | 
 | ||||||
|     const results: BulkIdResponseDto[] = []; |     const results: BulkIdResponseDto[] = []; | ||||||
| 
 | 
 | ||||||
|     for (const mergeId of mergeIds) { |     for (const mergeId of mergeIds) { | ||||||
|  |       const hasPermission = await this.access.hasPermission(authUser, Permission.PERSON_MERGE, mergeId); | ||||||
|  | 
 | ||||||
|  |       if (!hasPermission) { | ||||||
|  |         results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NO_PERMISSION }); | ||||||
|  |         continue; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|       try { |       try { | ||||||
|         const mergePerson = await this.repository.getById(authUser.id, mergeId); |         const mergePerson = await this.repository.getById(mergeId); | ||||||
|         if (!mergePerson) { |         if (!mergePerson) { | ||||||
|           results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NOT_FOUND }); |           results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NOT_FOUND }); | ||||||
|           continue; |           continue; | ||||||
| @ -188,8 +204,8 @@ export class PersonService { | |||||||
|     return dto.name !== undefined || dto.isHidden !== undefined; |     return dto.name !== undefined || dto.isHidden !== undefined; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   private async findOrFail(authUser: AuthUserDto, id: string) { |   private async findOrFail(id: string) { | ||||||
|     const person = await this.repository.getById(authUser.id, id); |     const person = await this.repository.getById(id); | ||||||
|     if (!person) { |     if (!person) { | ||||||
|       throw new BadRequestException('Person not found'); |       throw new BadRequestException('Person not found'); | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -1,13 +1,14 @@ | |||||||
| import { IAccessRepository } from '@app/domain'; | import { IAccessRepository } from '@app/domain'; | ||||||
| import { InjectRepository } from '@nestjs/typeorm'; | import { InjectRepository } from '@nestjs/typeorm'; | ||||||
| import { Repository } from 'typeorm'; | import { Repository } from 'typeorm'; | ||||||
| import { AlbumEntity, AssetEntity, PartnerEntity, SharedLinkEntity } from '../entities'; | import { AlbumEntity, AssetEntity, PartnerEntity, PersonEntity, SharedLinkEntity } from '../entities'; | ||||||
| 
 | 
 | ||||||
| export class AccessRepository implements IAccessRepository { | export class AccessRepository implements IAccessRepository { | ||||||
|   constructor( |   constructor( | ||||||
|     @InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>, |     @InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>, | ||||||
|     @InjectRepository(AlbumEntity) private albumRepository: Repository<AlbumEntity>, |     @InjectRepository(AlbumEntity) private albumRepository: Repository<AlbumEntity>, | ||||||
|     @InjectRepository(PartnerEntity) private partnerRepository: Repository<PartnerEntity>, |     @InjectRepository(PartnerEntity) private partnerRepository: Repository<PartnerEntity>, | ||||||
|  |     @InjectRepository(PersonEntity) private personRepository: Repository<PersonEntity>, | ||||||
|     @InjectRepository(SharedLinkEntity) private sharedLinkRepository: Repository<SharedLinkEntity>, |     @InjectRepository(SharedLinkEntity) private sharedLinkRepository: Repository<SharedLinkEntity>, | ||||||
|   ) {} |   ) {} | ||||||
| 
 | 
 | ||||||
| @ -156,4 +157,15 @@ export class AccessRepository implements IAccessRepository { | |||||||
|       }); |       }); | ||||||
|     }, |     }, | ||||||
|   }; |   }; | ||||||
|  | 
 | ||||||
|  |   person = { | ||||||
|  |     hasOwnerAccess: (userId: string, personId: string): Promise<boolean> => { | ||||||
|  |       return this.personRepository.exist({ | ||||||
|  |         where: { | ||||||
|  |           id: personId, | ||||||
|  |           ownerId: userId, | ||||||
|  |         }, | ||||||
|  |       }); | ||||||
|  |     }, | ||||||
|  |   }; | ||||||
| } | } | ||||||
|  | |||||||
| @ -86,14 +86,13 @@ export class PersonRepository implements IPersonRepository { | |||||||
|       .getMany(); |       .getMany(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   getById(ownerId: string, personId: string): Promise<PersonEntity | null> { |   getById(personId: string): Promise<PersonEntity | null> { | ||||||
|     return this.personRepository.findOne({ where: { id: personId, ownerId } }); |     return this.personRepository.findOne({ where: { id: personId } }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   getAssets(ownerId: string, personId: string): Promise<AssetEntity[]> { |   getAssets(personId: string): Promise<AssetEntity[]> { | ||||||
|     return this.assetRepository.find({ |     return this.assetRepository.find({ | ||||||
|       where: { |       where: { | ||||||
|         ownerId, |  | ||||||
|         faces: { |         faces: { | ||||||
|           personId, |           personId, | ||||||
|         }, |         }, | ||||||
|  | |||||||
| @ -4,6 +4,7 @@ export interface IAccessRepositoryMock { | |||||||
|   asset: jest.Mocked<IAccessRepository['asset']>; |   asset: jest.Mocked<IAccessRepository['asset']>; | ||||||
|   album: jest.Mocked<IAccessRepository['album']>; |   album: jest.Mocked<IAccessRepository['album']>; | ||||||
|   library: jest.Mocked<IAccessRepository['library']>; |   library: jest.Mocked<IAccessRepository['library']>; | ||||||
|  |   person: jest.Mocked<IAccessRepository['person']>; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export const newAccessRepositoryMock = (): IAccessRepositoryMock => { | export const newAccessRepositoryMock = (): IAccessRepositoryMock => { | ||||||
| @ -24,5 +25,9 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => { | |||||||
|     library: { |     library: { | ||||||
|       hasPartnerAccess: jest.fn(), |       hasPartnerAccess: jest.fn(), | ||||||
|     }, |     }, | ||||||
|  | 
 | ||||||
|  |     person: { | ||||||
|  |       hasOwnerAccess: jest.fn(), | ||||||
|  |     }, | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user