mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
feat (server, web): Share with partner (#2388)
* feat(server, web): implement share with partner * chore: regenerate api * chore: regenerate api * Pass userId to getAssetCountByTimeBucket and getAssetByTimeBucket * chore: regenerate api * Use AssetGrid to view partner's assets * Remove disableNavBarActions flag * Check access to buckets * Apply suggestions from code review Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> * Remove exception rethrowing * Simplify partner access check * Create new PartnerController * chore api:generate * Use partnerApi * Remove id from PartnerResponseDto * Refactor PartnerEntity * Rename args * Remove duplicate code in getAll * Create composite primary keys for partners table * Move asset access check into PartnerCore * Remove redundant getUserAssets call * Remove unused getUserAssets method * chore: regenerate api * Simplify getAll * Replace ?? with || * Simplify PartnerRepository.create * Introduce PartnerIds interface * Replace two database migrations with one * Simplify getAll * Change PartnerResponseDto to include UserResponseDto * Move partner sharing endpoints to PartnerController * Rename ShareController to SharedLinkController * chore: regenerate api after rebase * refactor: shared link remove return type * refactor: return user response dto * chore: regenerate open api * refactor: partner getAll * refactor: partner settings event typing * chore: remove unused code * refactor: add partners modal trigger * refactor: update url for viewing partner photos * feat: update partner sharing title * refactor: rename service method names * refactor: http exception logic to service, PartnerIds interface * chore: regenerate open api * test: coverage for domain code * fix: addPartner => createPartner * fix: missed rename * refactor: more code cleanup * chore: alphabetize settings order * feat: stop sharing confirmation modal * Enhance contrast of the email in dark mode * Replace button with CircleIconButton * Fix linter warning * Fix date types for PartnerEntity * Fix PartnerEntity creation * Reset assetStore state * Change layout of the partner's assets page * Add bulk download action for partner's assets --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
@@ -8,7 +8,14 @@ import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
|
||||
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
|
||||
import { DownloadService } from '../../modules/download/download.service';
|
||||
import { AlbumRepository, IAlbumRepository } from '../album/album-repository';
|
||||
import { ICryptoRepository, IJobRepository, ISharedLinkRepository, IStorageRepository, JobName } from '@app/domain';
|
||||
import {
|
||||
ICryptoRepository,
|
||||
IJobRepository,
|
||||
IPartnerRepository,
|
||||
ISharedLinkRepository,
|
||||
IStorageRepository,
|
||||
JobName,
|
||||
} from '@app/domain';
|
||||
import {
|
||||
assetEntityStub,
|
||||
authStub,
|
||||
@@ -126,6 +133,7 @@ describe('AssetService', () => {
|
||||
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
|
||||
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
|
||||
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
|
||||
let partnerRepositoryMock: jest.Mocked<IPartnerRepository>;
|
||||
let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
|
||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
@@ -178,6 +186,7 @@ describe('AssetService', () => {
|
||||
jobMock,
|
||||
cryptoMock,
|
||||
storageMock,
|
||||
partnerRepositoryMock,
|
||||
);
|
||||
|
||||
when(assetRepositoryMock.get)
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
mapAssetWithoutExif,
|
||||
MapMarkerResponseDto,
|
||||
mapAssetMapMarker,
|
||||
PartnerCore,
|
||||
} from '@app/domain';
|
||||
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
|
||||
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
|
||||
@@ -56,6 +57,7 @@ import { DownloadService } from '../../modules/download/download.service';
|
||||
import { DownloadDto } from './dto/download-library.dto';
|
||||
import { IAlbumRepository } from '../album/album-repository';
|
||||
import { ShareCore } from '@app/domain';
|
||||
import { IPartnerRepository } from '@app/domain';
|
||||
import { ISharedLinkRepository } from '@app/domain';
|
||||
import { DownloadFilesDto } from './dto/download-files.dto';
|
||||
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
|
||||
@@ -76,6 +78,7 @@ export class AssetService {
|
||||
readonly logger = new Logger(AssetService.name);
|
||||
private shareCore: ShareCore;
|
||||
private assetCore: AssetCore;
|
||||
private partnerCore: PartnerCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IAssetRepository) private _assetRepository: IAssetRepository,
|
||||
@@ -87,9 +90,11 @@ export class AssetService {
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
|
||||
) {
|
||||
this.assetCore = new AssetCore(_assetRepository, jobRepository);
|
||||
this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
|
||||
this.partnerCore = new PartnerCore(partnerRepository);
|
||||
}
|
||||
|
||||
public async uploadFile(
|
||||
@@ -154,7 +159,14 @@ export class AssetService {
|
||||
authUser: AuthUserDto,
|
||||
getAssetByTimeBucketDto: GetAssetByTimeBucketDto,
|
||||
): Promise<AssetResponseDto[]> {
|
||||
const assets = await this._assetRepository.getAssetByTimeBucket(authUser.id, getAssetByTimeBucketDto);
|
||||
if (getAssetByTimeBucketDto.userId) {
|
||||
await this.checkUserAccess(authUser, getAssetByTimeBucketDto.userId);
|
||||
}
|
||||
|
||||
const assets = await this._assetRepository.getAssetByTimeBucket(
|
||||
getAssetByTimeBucketDto.userId || authUser.id,
|
||||
getAssetByTimeBucketDto,
|
||||
);
|
||||
|
||||
return assets.map((asset) => mapAsset(asset));
|
||||
}
|
||||
@@ -458,8 +470,12 @@ export class AssetService {
|
||||
authUser: AuthUserDto,
|
||||
getAssetCountByTimeBucketDto: GetAssetCountByTimeBucketDto,
|
||||
): Promise<AssetCountByTimeBucketResponseDto> {
|
||||
if (getAssetCountByTimeBucketDto.userId !== undefined) {
|
||||
await this.checkUserAccess(authUser, getAssetCountByTimeBucketDto.userId);
|
||||
}
|
||||
|
||||
const result = await this._assetRepository.getAssetCountByTimeBucket(
|
||||
authUser.id,
|
||||
getAssetCountByTimeBucketDto.userId || authUser.id,
|
||||
getAssetCountByTimeBucketDto.timeGroup,
|
||||
);
|
||||
|
||||
@@ -492,6 +508,12 @@ export class AssetService {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Step 3: Check if any partner owns the asset
|
||||
const canAccess = await this.partnerCore.hasAssetAccess(assetId, authUser.id);
|
||||
if (canAccess) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Avoid additional checks if ownership is required
|
||||
if (!mustBeOwner) {
|
||||
// Step 2: Check if asset is part of an album shared with me
|
||||
@@ -505,6 +527,13 @@ export class AssetService {
|
||||
}
|
||||
}
|
||||
|
||||
private async checkUserAccess(authUser: AuthUserDto, userId: string) {
|
||||
// Check if userId shares assets with authUser
|
||||
if (!(await this.partnerCore.get({ sharedById: userId, sharedWithId: authUser.id }))) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
checkDownloadAccess(authUser: AuthUserDto) {
|
||||
this.shareCore.checkDownloadAccess(authUser);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
import { IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class GetAssetByTimeBucketDto {
|
||||
@IsNotEmpty()
|
||||
@@ -10,4 +10,9 @@ export class GetAssetByTimeBucketDto {
|
||||
example: ['2015-06-01T00:00:00.000Z', '2016-02-01T00:00:00.000Z', '2016-03-01T00:00:00.000Z'],
|
||||
})
|
||||
timeBucket!: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
import { IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export enum TimeGroupEnum {
|
||||
Day = 'day',
|
||||
@@ -14,4 +14,9 @@ export class GetAssetCountByTimeBucketDto {
|
||||
enumName: 'TimeGroupEnum',
|
||||
})
|
||||
timeGroup!: TimeGroupEnum;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
@@ -12,9 +12,10 @@ import {
|
||||
AuthController,
|
||||
JobController,
|
||||
OAuthController,
|
||||
PartnerController,
|
||||
SearchController,
|
||||
ServerInfoController,
|
||||
ShareController,
|
||||
SharedLinkController,
|
||||
SystemConfigController,
|
||||
UserController,
|
||||
} from './controllers';
|
||||
@@ -37,9 +38,10 @@ import { AppCronJobs } from './app.cron-jobs';
|
||||
AuthController,
|
||||
JobController,
|
||||
OAuthController,
|
||||
PartnerController,
|
||||
SearchController,
|
||||
ServerInfoController,
|
||||
ShareController,
|
||||
SharedLinkController,
|
||||
SystemConfigController,
|
||||
UserController,
|
||||
],
|
||||
|
||||
@@ -3,8 +3,9 @@ export * from './api-key.controller';
|
||||
export * from './auth.controller';
|
||||
export * from './job.controller';
|
||||
export * from './oauth.controller';
|
||||
export * from './partner.controller';
|
||||
export * from './search.controller';
|
||||
export * from './server-info.controller';
|
||||
export * from './share.controller';
|
||||
export * from './shared-link.controller';
|
||||
export * from './system-config.controller';
|
||||
export * from './user.controller';
|
||||
|
||||
36
server/apps/immich/src/controllers/partner.controller.ts
Normal file
36
server/apps/immich/src/controllers/partner.controller.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { PartnerDirection, PartnerService, UserResponseDto } from '@app/domain';
|
||||
import { Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
import { AuthUserDto, GetAuthUser } from '../decorators/auth-user.decorator';
|
||||
import { Authenticated } from '../decorators/authenticated.decorator';
|
||||
import { UseValidation } from '../decorators/use-validation.decorator';
|
||||
import { UUIDParamDto } from './dto/uuid-param.dto';
|
||||
|
||||
@ApiTags('Partner')
|
||||
@Controller('partner')
|
||||
@UseValidation()
|
||||
export class PartnerController {
|
||||
constructor(private service: PartnerService) {}
|
||||
|
||||
@Authenticated()
|
||||
@Get()
|
||||
@ApiQuery({ name: 'direction', type: 'string', enum: PartnerDirection, required: true })
|
||||
getPartners(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Query('direction') direction: PartnerDirection,
|
||||
): Promise<UserResponseDto[]> {
|
||||
return this.service.getAll(authUser, direction);
|
||||
}
|
||||
|
||||
@Authenticated()
|
||||
@Post(':id')
|
||||
createPartner(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<UserResponseDto> {
|
||||
return this.service.create(authUser, id);
|
||||
}
|
||||
|
||||
@Authenticated()
|
||||
@Delete(':id')
|
||||
removePartner(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
return this.service.remove(authUser, id);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { UUIDParamDto } from './dto/uuid-param.dto';
|
||||
@ApiTags('share')
|
||||
@Controller('share')
|
||||
@UseValidation()
|
||||
export class ShareController {
|
||||
export class SharedLinkController {
|
||||
constructor(private readonly service: ShareService) {}
|
||||
|
||||
@Authenticated()
|
||||
Reference in New Issue
Block a user