refactor(server): partner core (#2678)

* refactor(server): partner core

* refactor(server): partner access check
This commit is contained in:
Jason Rasmussen 2023-06-06 16:18:38 -04:00 committed by GitHub
parent d1db479727
commit 6ce35d47f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 85 additions and 84 deletions

View File

@ -9,9 +9,9 @@ import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-use
import { DownloadService } from '../../modules/download/download.service'; import { DownloadService } from '../../modules/download/download.service';
import { AlbumRepository, IAlbumRepository } from '../album/album-repository'; import { AlbumRepository, IAlbumRepository } from '../album/album-repository';
import { import {
IAccessRepository,
ICryptoRepository, ICryptoRepository,
IJobRepository, IJobRepository,
IPartnerRepository,
ISharedLinkRepository, ISharedLinkRepository,
IStorageRepository, IStorageRepository,
JobName, JobName,
@ -20,6 +20,7 @@ import {
assetEntityStub, assetEntityStub,
authStub, authStub,
fileStub, fileStub,
newAccessRepositoryMock,
newCryptoRepositoryMock, newCryptoRepositoryMock,
newJobRepositoryMock, newJobRepositoryMock,
newSharedLinkRepositoryMock, newSharedLinkRepositoryMock,
@ -131,10 +132,10 @@ const _getArchivedAssetsCountByUserId = (): AssetCountByUserIdResponseDto => {
describe('AssetService', () => { describe('AssetService', () => {
let sut: AssetService; let sut: AssetService;
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
let accessMock: jest.Mocked<IAccessRepository>;
let assetRepositoryMock: jest.Mocked<IAssetRepository>; let assetRepositoryMock: jest.Mocked<IAssetRepository>;
let albumRepositoryMock: jest.Mocked<IAlbumRepository>; let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>; let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
let partnerRepositoryMock: jest.Mocked<IPartnerRepository>;
let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>; let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>; let cryptoMock: jest.Mocked<ICryptoRepository>;
let jobMock: jest.Mocked<IJobRepository>; let jobMock: jest.Mocked<IJobRepository>;
@ -173,12 +174,14 @@ describe('AssetService', () => {
downloadArchive: jest.fn(), downloadArchive: jest.fn(),
}; };
accessMock = newAccessRepositoryMock();
sharedLinkRepositoryMock = newSharedLinkRepositoryMock(); sharedLinkRepositoryMock = newSharedLinkRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
cryptoMock = newCryptoRepositoryMock(); cryptoMock = newCryptoRepositoryMock();
storageMock = newStorageRepositoryMock(); storageMock = newStorageRepositoryMock();
sut = new AssetService( sut = new AssetService(
accessMock,
assetRepositoryMock, assetRepositoryMock,
albumRepositoryMock, albumRepositoryMock,
a, a,
@ -187,7 +190,6 @@ describe('AssetService', () => {
jobMock, jobMock,
cryptoMock, cryptoMock,
storageMock, storageMock,
partnerRepositoryMock,
); );
when(assetRepositoryMock.get) when(assetRepositoryMock.get)

View File

@ -25,12 +25,12 @@ import { CuratedObjectsResponseDto } from './response-dto/curated-objects-respon
import { import {
AssetResponseDto, AssetResponseDto,
getLivePhotoMotionFilename, getLivePhotoMotionFilename,
IAccessRepository,
ImmichReadStream, ImmichReadStream,
IStorageRepository, IStorageRepository,
JobName, JobName,
mapAsset, mapAsset,
mapAssetWithoutExif, mapAssetWithoutExif,
PartnerCore,
} from '@app/domain'; } from '@app/domain';
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto'; import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto'; import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
@ -55,7 +55,6 @@ import { DownloadService } from '../../modules/download/download.service';
import { DownloadDto } from './dto/download-library.dto'; import { DownloadDto } from './dto/download-library.dto';
import { IAlbumRepository } from '../album/album-repository'; import { IAlbumRepository } from '../album/album-repository';
import { SharedLinkCore } from '@app/domain'; import { SharedLinkCore } from '@app/domain';
import { IPartnerRepository } from '@app/domain';
import { ISharedLinkRepository } from '@app/domain'; import { ISharedLinkRepository } from '@app/domain';
import { DownloadFilesDto } from './dto/download-files.dto'; import { DownloadFilesDto } from './dto/download-files.dto';
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto'; import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
@ -82,9 +81,9 @@ export class AssetService {
readonly logger = new Logger(AssetService.name); readonly logger = new Logger(AssetService.name);
private shareCore: SharedLinkCore; private shareCore: SharedLinkCore;
private assetCore: AssetCore; private assetCore: AssetCore;
private partnerCore: PartnerCore;
constructor( constructor(
@Inject(IAccessRepository) private accessRepository: IAccessRepository,
@Inject(IAssetRepository) private _assetRepository: IAssetRepository, @Inject(IAssetRepository) private _assetRepository: IAssetRepository,
@Inject(IAlbumRepository) private _albumRepository: IAlbumRepository, @Inject(IAlbumRepository) private _albumRepository: IAlbumRepository,
@InjectRepository(AssetEntity) @InjectRepository(AssetEntity)
@ -94,11 +93,9 @@ export class AssetService {
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
) { ) {
this.assetCore = new AssetCore(_assetRepository, jobRepository); this.assetCore = new AssetCore(_assetRepository, jobRepository);
this.shareCore = new SharedLinkCore(sharedLinkRepository, cryptoRepository); this.shareCore = new SharedLinkCore(sharedLinkRepository, cryptoRepository);
this.partnerCore = new PartnerCore(partnerRepository);
} }
public async uploadFile( public async uploadFile(
@ -581,7 +578,7 @@ export class AssetService {
} }
// Step 3: Check if any partner owns the asset // Step 3: Check if any partner owns the asset
const canAccess = await this.partnerCore.hasAssetAccess(assetId, authUser.id); const canAccess = await this.accessRepository.hasPartnerAssetAccess(authUser.id, assetId);
if (canAccess) { if (canAccess) {
continue; continue;
} }
@ -601,7 +598,8 @@ export class AssetService {
private async checkUserAccess(authUser: AuthUserDto, userId: string) { private async checkUserAccess(authUser: AuthUserDto, userId: string) {
// Check if userId shares assets with authUser // Check if userId shares assets with authUser
if (!(await this.partnerCore.get({ sharedById: userId, sharedWithId: authUser.id }))) { const canAccess = await this.accessRepository.hasPartnerAccess(authUser.id, userId);
if (!canAccess) {
throw new ForbiddenException(); throw new ForbiddenException();
} }
} }

View File

@ -0,0 +1,6 @@
export const IAccessRepository = 'IAccessRepository';
export interface IAccessRepository {
hasPartnerAccess(userId: string, partnerId: string): Promise<boolean>;
hasPartnerAssetAccess(userId: string, assetId: string): Promise<boolean>;
}

View File

@ -0,0 +1 @@
export * from './access.repository';

View File

@ -1,3 +1,4 @@
export * from './access';
export * from './album'; export * from './album';
export * from './api-key'; export * from './api-key';
export * from './asset'; export * from './asset';
@ -13,10 +14,10 @@ export * from './job';
export * from './media'; export * from './media';
export * from './metadata'; export * from './metadata';
export * from './oauth'; export * from './oauth';
export * from './partner';
export * from './person'; export * from './person';
export * from './search'; export * from './search';
export * from './server-info'; export * from './server-info';
export * from './partner';
export * from './shared-link'; export * from './shared-link';
export * from './smart-info'; export * from './smart-info';
export * from './storage'; export * from './storage';

View File

@ -1,3 +1,2 @@
export * from './partner.core';
export * from './partner.repository'; export * from './partner.repository';
export * from './partner.service'; export * from './partner.service';

View File

@ -1,33 +0,0 @@
import { PartnerEntity } from '@app/infra/entities';
import { IPartnerRepository, PartnerIds } from './partner.repository';
export enum PartnerDirection {
SharedBy = 'shared-by',
SharedWith = 'shared-with',
}
export class PartnerCore {
constructor(private repository: IPartnerRepository) {}
async getAll(userId: string, direction: PartnerDirection): Promise<PartnerEntity[]> {
const partners = await this.repository.getAll(userId);
const key = direction === PartnerDirection.SharedBy ? 'sharedById' : 'sharedWithId';
return partners.filter((partner) => partner[key] === userId);
}
get(ids: PartnerIds): Promise<PartnerEntity | null> {
return this.repository.get(ids);
}
async create(ids: PartnerIds): Promise<PartnerEntity> {
return this.repository.create(ids);
}
async remove(ids: PartnerIds): Promise<void> {
await this.repository.remove(ids as PartnerEntity);
}
hasAssetAccess(assetId: string, userId: string): Promise<boolean> {
return this.repository.hasAssetAccess(assetId, userId);
}
}

View File

@ -5,6 +5,11 @@ export interface PartnerIds {
sharedWithId: string; sharedWithId: string;
} }
export enum PartnerDirection {
SharedBy = 'shared-by',
SharedWith = 'shared-with',
}
export const IPartnerRepository = 'IPartnerRepository'; export const IPartnerRepository = 'IPartnerRepository';
export interface IPartnerRepository { export interface IPartnerRepository {
@ -12,5 +17,4 @@ export interface IPartnerRepository {
get(partner: PartnerIds): Promise<PartnerEntity | null>; get(partner: PartnerIds): Promise<PartnerEntity | null>;
create(partner: PartnerIds): Promise<PartnerEntity>; create(partner: PartnerIds): Promise<PartnerEntity>;
remove(entity: PartnerEntity): Promise<void>; remove(entity: PartnerEntity): Promise<void>;
hasAssetAccess(assetId: string, userId: string): Promise<boolean>;
} }

View File

@ -1,7 +1,6 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { authStub, newPartnerRepositoryMock, partnerStub } from '../../test'; import { authStub, newPartnerRepositoryMock, partnerStub } from '../../test';
import { PartnerDirection } from './partner.core'; import { IPartnerRepository, PartnerDirection } from './partner.repository';
import { IPartnerRepository } from './partner.repository';
import { PartnerService } from './partner.service'; import { PartnerService } from './partner.service';
const responseDto = { const responseDto = {

View File

@ -1,41 +1,38 @@
import { PartnerEntity } from '@app/infra/entities'; import { PartnerEntity } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { AuthUserDto } from '../auth'; import { AuthUserDto } from '../auth';
import { IPartnerRepository, PartnerCore, PartnerDirection, PartnerIds } from '../partner'; import { IPartnerRepository, PartnerDirection, PartnerIds } from '../partner';
import { mapUser, UserResponseDto } from '../user'; import { mapUser, UserResponseDto } from '../user';
@Injectable() @Injectable()
export class PartnerService { export class PartnerService {
private partnerCore: PartnerCore; constructor(@Inject(IPartnerRepository) private repository: IPartnerRepository) {}
constructor(@Inject(IPartnerRepository) partnerRepository: IPartnerRepository) {
this.partnerCore = new PartnerCore(partnerRepository);
}
async create(authUser: AuthUserDto, sharedWithId: string): Promise<UserResponseDto> { async create(authUser: AuthUserDto, sharedWithId: string): Promise<UserResponseDto> {
const partnerId: PartnerIds = { sharedById: authUser.id, sharedWithId }; const partnerId: PartnerIds = { sharedById: authUser.id, sharedWithId };
const exists = await this.partnerCore.get(partnerId); const exists = await this.repository.get(partnerId);
if (exists) { if (exists) {
throw new BadRequestException(`Partner already exists`); throw new BadRequestException(`Partner already exists`);
} }
const partner = await this.partnerCore.create(partnerId); const partner = await this.repository.create(partnerId);
return this.map(partner, PartnerDirection.SharedBy); return this.map(partner, PartnerDirection.SharedBy);
} }
async remove(authUser: AuthUserDto, sharedWithId: string): Promise<void> { async remove(authUser: AuthUserDto, sharedWithId: string): Promise<void> {
const partnerId: PartnerIds = { sharedById: authUser.id, sharedWithId }; const partnerId: PartnerIds = { sharedById: authUser.id, sharedWithId };
const partner = await this.partnerCore.get(partnerId); const partner = await this.repository.get(partnerId);
if (!partner) { if (!partner) {
throw new BadRequestException('Partner not found'); throw new BadRequestException('Partner not found');
} }
await this.partnerCore.remove(partner); await this.repository.remove(partner);
} }
async getAll(authUser: AuthUserDto, direction: PartnerDirection): Promise<UserResponseDto[]> { async getAll(authUser: AuthUserDto, direction: PartnerDirection): Promise<UserResponseDto[]> {
const partners = await this.partnerCore.getAll(authUser.id, direction); const partners = await this.repository.getAll(authUser.id);
return partners.map((partner) => this.map(partner, direction)); const key = direction === PartnerDirection.SharedBy ? 'sharedById' : 'sharedWithId';
return partners.filter((partner) => partner[key] === authUser.id).map((partner) => this.map(partner, direction));
} }
private map(partner: PartnerEntity, direction: PartnerDirection): UserResponseDto { private map(partner: PartnerEntity, direction: PartnerDirection): UserResponseDto {

View File

@ -0,0 +1,8 @@
import { IAccessRepository } from '../src';
export const newAccessRepositoryMock = (): jest.Mocked<IAccessRepository> => {
return {
hasPartnerAccess: jest.fn(),
hasPartnerAssetAccess: jest.fn(),
};
};

View File

@ -1,3 +1,4 @@
export * from './access.repository.mock';
export * from './album.repository.mock'; export * from './album.repository.mock';
export * from './api-key.repository.mock'; export * from './api-key.repository.mock';
export * from './asset.repository.mock'; export * from './asset.repository.mock';

View File

@ -6,6 +6,5 @@ export const newPartnerRepositoryMock = (): jest.Mocked<IPartnerRepository> => {
remove: jest.fn(), remove: jest.fn(),
getAll: jest.fn(), getAll: jest.fn(),
get: jest.fn(), get: jest.fn(),
hasAssetAccess: jest.fn(),
}; };
}; };

View File

@ -1,4 +1,5 @@
import { import {
IAccessRepository,
IAlbumRepository, IAlbumRepository,
IAssetRepository, IAssetRepository,
ICommunicationRepository, ICommunicationRepository,
@ -30,6 +31,7 @@ import { databaseConfig } from './database.config';
import { databaseEntities } from './entities'; import { databaseEntities } from './entities';
import { bullConfig, bullQueues } from './infra.config'; import { bullConfig, bullQueues } from './infra.config';
import { import {
AccessRepository,
AlbumRepository, AlbumRepository,
APIKeyRepository, APIKeyRepository,
AssetRepository, AssetRepository,
@ -53,6 +55,7 @@ import {
} from './repositories'; } from './repositories';
const providers: Provider[] = [ const providers: Provider[] = [
{ provide: IAccessRepository, useClass: AccessRepository },
{ provide: IAlbumRepository, useClass: AlbumRepository }, { provide: IAlbumRepository, useClass: AlbumRepository },
{ provide: IAssetRepository, useClass: AssetRepository }, { provide: IAssetRepository, useClass: AssetRepository },
{ provide: ICommunicationRepository, useClass: CommunicationRepository }, { provide: ICommunicationRepository, useClass: CommunicationRepository },

View File

@ -0,0 +1,38 @@
import { IAccessRepository } from '@app/domain';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PartnerEntity } from '../entities';
export class AccessRepository implements IAccessRepository {
constructor(@InjectRepository(PartnerEntity) private partnerRepository: Repository<PartnerEntity>) {}
hasPartnerAccess(userId: string, partnerId: string): Promise<boolean> {
return this.partnerRepository.exist({
where: {
sharedWithId: userId,
sharedById: partnerId,
},
});
}
hasPartnerAssetAccess(userId: string, assetId: string): Promise<boolean> {
return this.partnerRepository.exist({
where: {
sharedWith: {
id: userId,
},
sharedBy: {
assets: {
id: assetId,
},
},
},
relations: {
sharedWith: true,
sharedBy: {
assets: true,
},
},
});
}
}

View File

@ -1,3 +1,4 @@
export * from './access.repository';
export * from './album.repository'; export * from './album.repository';
export * from './api-key.repository'; export * from './api-key.repository';
export * from './asset.repository'; export * from './asset.repository';

View File

@ -24,27 +24,4 @@ export class PartnerRepository implements IPartnerRepository {
async remove(entity: PartnerEntity): Promise<void> { async remove(entity: PartnerEntity): Promise<void> {
await this.repository.remove(entity); await this.repository.remove(entity);
} }
async hasAssetAccess(assetId: string, userId: string): Promise<boolean> {
const count = await this.repository.count({
where: {
sharedWith: {
id: userId,
},
sharedBy: {
assets: {
id: assetId,
},
},
},
relations: {
sharedWith: true,
sharedBy: {
assets: true,
},
},
});
return count == 1;
}
} }