mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
refactor(server,web): add/remove album users (#2681)
* refactor(server,web): add/remove album users * fix(web): bug fixes for multiple users * fix: linting
This commit is contained in:
@@ -1,7 +1,17 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { albumStub, authStub, newAlbumRepositoryMock, newAssetRepositoryMock, newJobRepositoryMock } from '../../test';
|
||||
import _ from 'lodash';
|
||||
import {
|
||||
albumStub,
|
||||
authStub,
|
||||
newAlbumRepositoryMock,
|
||||
newAssetRepositoryMock,
|
||||
newJobRepositoryMock,
|
||||
newUserRepositoryMock,
|
||||
userEntityStub,
|
||||
} from '../../test';
|
||||
import { IAssetRepository } from '../asset';
|
||||
import { IJobRepository, JobName } from '../job';
|
||||
import { IUserRepository } from '../user';
|
||||
import { IAlbumRepository } from './album.repository';
|
||||
import { AlbumService } from './album.service';
|
||||
|
||||
@@ -10,13 +20,15 @@ describe(AlbumService.name, () => {
|
||||
let albumMock: jest.Mocked<IAlbumRepository>;
|
||||
let assetMock: jest.Mocked<IAssetRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
let userMock: jest.Mocked<IUserRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
albumMock = newAlbumRepositoryMock();
|
||||
assetMock = newAssetRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
|
||||
sut = new AlbumService(albumMock, assetMock, jobMock);
|
||||
sut = new AlbumService(albumMock, assetMock, jobMock, userMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
@@ -152,6 +164,18 @@ describe(AlbumService.name, () => {
|
||||
data: { ids: [albumStub.empty.id] },
|
||||
});
|
||||
});
|
||||
|
||||
it('should require valid userIds', async () => {
|
||||
userMock.get.mockResolvedValue(null);
|
||||
await expect(
|
||||
sut.create(authStub.admin, {
|
||||
albumName: 'Empty album',
|
||||
sharedWithUserIds: ['user-3'],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(userMock.get).toHaveBeenCalledWith('user-3');
|
||||
expect(albumMock.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
@@ -240,4 +264,130 @@ describe(AlbumService.name, () => {
|
||||
expect(albumMock.delete).toHaveBeenCalledWith(albumStub.empty);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addUsers', () => {
|
||||
it('should require a valid album id', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([]);
|
||||
await expect(sut.addUsers(authStub.admin, 'album-1', { sharedUserIds: ['user-1'] })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require the user to be the owner', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
|
||||
await expect(
|
||||
sut.addUsers(authStub.admin, albumStub.sharedWithAdmin.id, { sharedUserIds: ['user-1'] }),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the userId is already added', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
|
||||
await expect(
|
||||
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.admin.id] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the userId does not exist', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
|
||||
userMock.get.mockResolvedValue(null);
|
||||
await expect(
|
||||
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: ['user-3'] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should add valid shared users', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([_.cloneDeep(albumStub.sharedWithAdmin)]);
|
||||
albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||
userMock.get.mockResolvedValue(userEntityStub.user2);
|
||||
await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.user2.id] });
|
||||
expect(albumMock.update).toHaveBeenCalledWith({
|
||||
id: albumStub.sharedWithAdmin.id,
|
||||
updatedAt: expect.any(Date),
|
||||
sharedUsers: [userEntityStub.admin, { id: authStub.user2.id }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeUser', () => {
|
||||
it('should require a valid album id', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([]);
|
||||
await expect(sut.removeUser(authStub.admin, 'album-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove a shared user from an owned album', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]);
|
||||
|
||||
await expect(
|
||||
sut.removeUser(authStub.admin, albumStub.sharedWithUser.id, userEntityStub.user1.id),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(albumMock.update).toHaveBeenCalledTimes(1);
|
||||
expect(albumMock.update).toHaveBeenCalledWith({
|
||||
id: albumStub.sharedWithUser.id,
|
||||
updatedAt: expect.any(Date),
|
||||
sharedUsers: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithMultiple]);
|
||||
|
||||
await expect(
|
||||
sut.removeUser(authStub.user1, albumStub.sharedWithMultiple.id, authStub.user2.id),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow a shared user to remove themselves', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]);
|
||||
|
||||
await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, authStub.user1.id);
|
||||
|
||||
expect(albumMock.update).toHaveBeenCalledTimes(1);
|
||||
expect(albumMock.update).toHaveBeenCalledWith({
|
||||
id: albumStub.sharedWithUser.id,
|
||||
updatedAt: expect.any(Date),
|
||||
sharedUsers: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow a shared user to remove themselves using "me"', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]);
|
||||
|
||||
await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, 'me');
|
||||
|
||||
expect(albumMock.update).toHaveBeenCalledTimes(1);
|
||||
expect(albumMock.update).toHaveBeenCalledWith({
|
||||
id: albumStub.sharedWithUser.id,
|
||||
updatedAt: expect.any(Date),
|
||||
sharedUsers: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not allow the owner to be removed', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.empty]);
|
||||
|
||||
await expect(sut.removeUser(authStub.admin, albumStub.empty.id, authStub.admin.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error for a user not in the album', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.empty]);
|
||||
|
||||
await expect(sut.removeUser(authStub.admin, albumStub.empty.id, 'user-3')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,9 @@ import { BadRequestException, ForbiddenException, Inject, Injectable } from '@ne
|
||||
import { IAssetRepository, mapAsset } from '../asset';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { IJobRepository, JobName } from '../job';
|
||||
import { IUserRepository } from '../user';
|
||||
import { IAlbumRepository } from './album.repository';
|
||||
import { CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto } from './dto';
|
||||
import { AddUsersDto, CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto } from './dto';
|
||||
import { AlbumResponseDto, mapAlbum } from './response-dto';
|
||||
|
||||
@Injectable()
|
||||
@@ -13,6 +14,7 @@ export class AlbumService {
|
||||
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||
) {}
|
||||
|
||||
async getAll({ id: ownerId }: AuthUserDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
|
||||
@@ -48,7 +50,7 @@ export class AlbumService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateInvalidThumbnails(): Promise<number> {
|
||||
private async updateInvalidThumbnails(): Promise<number> {
|
||||
const invalidAlbumIds = await this.albumRepository.getInvalidThumbnail();
|
||||
|
||||
for (const albumId of invalidAlbumIds) {
|
||||
@@ -60,7 +62,13 @@ export class AlbumService {
|
||||
}
|
||||
|
||||
async create(authUser: AuthUserDto, dto: CreateAlbumDto): Promise<AlbumResponseDto> {
|
||||
// TODO: Handle nonexistent sharedWithUserIds and assetIds.
|
||||
for (const userId of dto.sharedWithUserIds || []) {
|
||||
const exists = await this.userRepository.get(userId);
|
||||
if (!exists) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
}
|
||||
|
||||
const album = await this.albumRepository.create({
|
||||
ownerId: authUser.id,
|
||||
albumName: dto.albumName,
|
||||
@@ -68,19 +76,14 @@ export class AlbumService {
|
||||
assets: (dto.assetIds || []).map((id) => ({ id } as AssetEntity)),
|
||||
albumThumbnailAssetId: dto.assetIds?.[0] || null,
|
||||
});
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [album.id] } });
|
||||
return mapAlbum(album);
|
||||
}
|
||||
|
||||
async update(authUser: AuthUserDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
|
||||
const [album] = await this.albumRepository.getByIds([id]);
|
||||
if (!album) {
|
||||
throw new BadRequestException('Album not found');
|
||||
}
|
||||
|
||||
if (album.ownerId !== authUser.id) {
|
||||
throw new ForbiddenException('Album not owned by user');
|
||||
}
|
||||
const album = await this.get(id);
|
||||
this.assertOwner(authUser, album);
|
||||
|
||||
if (dto.albumThumbnailAssetId) {
|
||||
const valid = await this.albumRepository.hasAsset(id, dto.albumThumbnailAssetId);
|
||||
@@ -113,4 +116,73 @@ export class AlbumService {
|
||||
await this.albumRepository.delete(album);
|
||||
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { ids: [id] } });
|
||||
}
|
||||
|
||||
async addUsers(authUser: AuthUserDto, id: string, dto: AddUsersDto) {
|
||||
const album = await this.get(id);
|
||||
this.assertOwner(authUser, album);
|
||||
|
||||
for (const userId of dto.sharedUserIds) {
|
||||
const exists = album.sharedUsers.find((user) => user.id === userId);
|
||||
if (exists) {
|
||||
throw new BadRequestException('User already added');
|
||||
}
|
||||
|
||||
const user = await this.userRepository.get(userId);
|
||||
if (!user) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
|
||||
album.sharedUsers.push({ id: userId } as UserEntity);
|
||||
}
|
||||
|
||||
return this.albumRepository
|
||||
.update({
|
||||
id: album.id,
|
||||
updatedAt: new Date(),
|
||||
sharedUsers: album.sharedUsers,
|
||||
})
|
||||
.then(mapAlbum);
|
||||
}
|
||||
|
||||
async removeUser(authUser: AuthUserDto, id: string, userId: string | 'me'): Promise<void> {
|
||||
if (userId === 'me') {
|
||||
userId = authUser.id;
|
||||
}
|
||||
|
||||
const album = await this.get(id);
|
||||
|
||||
if (album.ownerId === userId) {
|
||||
throw new BadRequestException('Cannot remove album owner');
|
||||
}
|
||||
|
||||
const exists = album.sharedUsers.find((user) => user.id === userId);
|
||||
if (!exists) {
|
||||
throw new BadRequestException('Album not shared with user');
|
||||
}
|
||||
|
||||
// non-admin can remove themselves
|
||||
if (authUser.id !== userId) {
|
||||
this.assertOwner(authUser, album);
|
||||
}
|
||||
|
||||
await this.albumRepository.update({
|
||||
id: album.id,
|
||||
updatedAt: new Date(),
|
||||
sharedUsers: album.sharedUsers.filter((user) => user.id !== userId),
|
||||
});
|
||||
}
|
||||
|
||||
private async get(id: string) {
|
||||
const [album] = await this.albumRepository.getByIds([id]);
|
||||
if (!album) {
|
||||
throw new BadRequestException('Album not found');
|
||||
}
|
||||
return album;
|
||||
}
|
||||
|
||||
private assertOwner(authUser: AuthUserDto, album: AlbumEntity) {
|
||||
if (album.ownerId !== authUser.id) {
|
||||
throw new ForbiddenException('Album not owned by user');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
server/libs/domain/src/album/dto/album-add-users.dto.ts
Normal file
8
server/libs/domain/src/album/dto/album-add-users.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ArrayNotEmpty } from 'class-validator';
|
||||
import { ValidateUUID } from '../../../../../apps/immich/src/decorators/validate-uuid.decorator';
|
||||
|
||||
export class AddUsersDto {
|
||||
@ValidateUUID({ each: true })
|
||||
@ArrayNotEmpty()
|
||||
sharedUserIds!: string[];
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './album-add-users.dto';
|
||||
export * from './album-create.dto';
|
||||
export * from './album-update.dto';
|
||||
export * from './get-albums.dto';
|
||||
|
||||
@@ -61,6 +61,16 @@ export const authStub = {
|
||||
isShowExif: true,
|
||||
accessTokenId: 'token-id',
|
||||
}),
|
||||
user2: Object.freeze<AuthUserDto>({
|
||||
id: 'user-2',
|
||||
email: 'user2@immich.app',
|
||||
isAdmin: false,
|
||||
isPublicUser: false,
|
||||
isAllowUpload: true,
|
||||
isAllowDownload: true,
|
||||
isShowExif: true,
|
||||
accessTokenId: 'token-id',
|
||||
}),
|
||||
adminSharedLink: Object.freeze<AuthUserDto>({
|
||||
id: 'admin_id',
|
||||
email: 'admin@test.com',
|
||||
@@ -125,6 +135,21 @@ export const userEntityStub = {
|
||||
tags: [],
|
||||
assets: [],
|
||||
}),
|
||||
user2: Object.freeze<UserEntity>({
|
||||
...authStub.user2,
|
||||
password: 'immich_password',
|
||||
firstName: 'immich_first_name',
|
||||
lastName: 'immich_last_name',
|
||||
storageLabel: null,
|
||||
oauthId: '',
|
||||
shouldChangePassword: false,
|
||||
profileImagePath: '',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
deletedAt: null,
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
tags: [],
|
||||
assets: [],
|
||||
}),
|
||||
storageLabel: Object.freeze<UserEntity>({
|
||||
...authStub.user1,
|
||||
password: 'immich_password',
|
||||
@@ -357,6 +382,19 @@ export const albumStub = {
|
||||
sharedLinks: [],
|
||||
sharedUsers: [userEntityStub.user1],
|
||||
}),
|
||||
sharedWithMultiple: Object.freeze<AlbumEntity>({
|
||||
id: 'album-3',
|
||||
albumName: 'Empty album shared with users',
|
||||
ownerId: authStub.admin.id,
|
||||
owner: userEntityStub.admin,
|
||||
assets: [],
|
||||
albumThumbnailAsset: null,
|
||||
albumThumbnailAssetId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
sharedLinks: [],
|
||||
sharedUsers: [userEntityStub.user1, userEntityStub.user2],
|
||||
}),
|
||||
sharedWithAdmin: Object.freeze<AlbumEntity>({
|
||||
id: 'album-3',
|
||||
albumName: 'Empty album shared with admin',
|
||||
|
||||
@@ -16,6 +16,7 @@ export class AlbumRepository implements IAlbumRepository {
|
||||
},
|
||||
relations: {
|
||||
owner: true,
|
||||
sharedUsers: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -153,6 +154,12 @@ export class AlbumRepository implements IAlbumRepository {
|
||||
|
||||
private async save(album: Partial<AlbumEntity>) {
|
||||
const { id } = await this.repository.save(album);
|
||||
return this.repository.findOneOrFail({ where: { id }, relations: { owner: true } });
|
||||
return this.repository.findOneOrFail({
|
||||
where: { id },
|
||||
relations: {
|
||||
owner: true,
|
||||
sharedUsers: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user