1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-27 10:58:13 +02: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:
Jason Rasmussen 2023-06-07 10:37:25 -04:00 committed by GitHub
parent 284edd97d6
commit eb1225a0a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 521 additions and 329 deletions

View File

@ -1,18 +1,15 @@
import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/entities'; import { AlbumEntity, AssetEntity } from '@app/infra/entities';
import { dataSource } from '@app/infra/database.config'; import { dataSource } from '@app/infra/database.config';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { AddAssetsDto } from './dto/add-assets.dto'; import { AddAssetsDto } from './dto/add-assets.dto';
import { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto'; import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto'; import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
export interface IAlbumRepository { export interface IAlbumRepository {
get(albumId: string): Promise<AlbumEntity | null>; get(albumId: string): Promise<AlbumEntity | null>;
addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity>;
removeUser(album: AlbumEntity, userId: string): Promise<void>;
removeAssets(album: AlbumEntity, removeAssets: RemoveAssetsDto): Promise<number>; removeAssets(album: AlbumEntity, removeAssets: RemoveAssetsDto): Promise<number>;
addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AddAssetsResponseDto>; addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AddAssetsResponseDto>;
updateThumbnails(): Promise<number | undefined>; updateThumbnails(): Promise<number | undefined>;
@ -25,11 +22,8 @@ export const IAlbumRepository = 'IAlbumRepository';
@Injectable() @Injectable()
export class AlbumRepository implements IAlbumRepository { export class AlbumRepository implements IAlbumRepository {
constructor( constructor(
@InjectRepository(AlbumEntity) @InjectRepository(AlbumEntity) private albumRepository: Repository<AlbumEntity>,
private albumRepository: Repository<AlbumEntity>, @InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
) {} ) {}
async getCountByUserId(userId: string): Promise<AlbumCountResponseDto> { async getCountByUserId(userId: string): Promise<AlbumCountResponseDto> {
@ -59,22 +53,6 @@ export class AlbumRepository implements IAlbumRepository {
}); });
} }
async addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity> {
album.sharedUsers.push(...addUsersDto.sharedUserIds.map((id) => ({ id } as UserEntity)));
album.updatedAt = new Date();
await this.albumRepository.save(album);
// need to re-load the shared user relation
return this.get(album.id) as Promise<AlbumEntity>;
}
async removeUser(album: AlbumEntity, userId: string): Promise<void> {
album.sharedUsers = album.sharedUsers.filter((user) => user.id !== userId);
album.updatedAt = new Date();
await this.albumRepository.save(album);
}
async removeAssets(album: AlbumEntity, removeAssetsDto: RemoveAssetsDto): Promise<number> { async removeAssets(album: AlbumEntity, removeAssetsDto: RemoveAssetsDto): Promise<number> {
const assetCount = album.assets.length; const assetCount = album.assets.length;

View File

@ -1,10 +1,8 @@
import { Controller, Get, Post, Body, Param, Delete, Put, Query, Response } from '@nestjs/common'; import { Controller, Get, Post, Body, Param, Delete, Put, Query, Response } from '@nestjs/common';
import { ParseMeUUIDPipe } from '../validation/parse-me-uuid-pipe';
import { AlbumService } from './album.service'; import { AlbumService } from './album.service';
import { Authenticated, SharedLinkRoute } from '../../decorators/authenticated.decorator'; import { Authenticated, SharedLinkRoute } from '../../decorators/authenticated.decorator';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { AddAssetsDto } from './dto/add-assets.dto'; import { AddAssetsDto } from './dto/add-assets.dto';
import { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto'; import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { AlbumResponseDto } from '@app/domain'; import { AlbumResponseDto } from '@app/domain';
@ -29,12 +27,6 @@ export class AlbumController {
return this.service.getCountByUserId(authUser); return this.service.getCountByUserId(authUser);
} }
@Put(':id/users')
addUsersToAlbum(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: AddUsersDto) {
// TODO: Handle nonexistent sharedUserIds.
return this.service.addUsers(authUser, id, dto);
}
@SharedLinkRoute() @SharedLinkRoute()
@Put(':id/assets') @Put(':id/assets')
addAssetsToAlbum( addAssetsToAlbum(
@ -62,15 +54,6 @@ export class AlbumController {
return this.service.removeAssets(authUser, id, dto); return this.service.removeAssets(authUser, id, dto);
} }
@Delete(':id/user/:userId')
removeUserFromAlbum(
@GetAuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Param('userId', new ParseMeUUIDPipe({ version: '4' })) userId: string,
) {
return this.service.removeUser(authUser, id, userId);
}
@SharedLinkRoute() @SharedLinkRoute()
@Get(':id/download') @Get(':id/download')
@ApiOkResponse({ content: { 'application/zip': { schema: { type: 'string', format: 'binary' } } } }) @ApiOkResponse({ content: { 'application/zip': { schema: { type: 'string', format: 'binary' } } } })

View File

@ -1,6 +1,6 @@
import { AlbumService } from './album.service'; import { AlbumService } from './album.service';
import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; import { NotFoundException, ForbiddenException } from '@nestjs/common';
import { AlbumEntity, UserEntity } from '@app/infra/entities'; import { AlbumEntity, UserEntity } from '@app/infra/entities';
import { AlbumResponseDto, ICryptoRepository, mapUser } from '@app/domain'; import { AlbumResponseDto, ICryptoRepository, mapUser } from '@app/domain';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
@ -39,7 +39,6 @@ describe('Album service', () => {
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58'; const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
const sharedAlbumOwnerId = '2222'; const sharedAlbumOwnerId = '2222';
const sharedAlbumSharedAlsoWithId = '3333'; const sharedAlbumSharedAlsoWithId = '3333';
const ownedAlbumSharedWithId = '4444';
const _getOwnedAlbum = () => { const _getOwnedAlbum = () => {
const albumEntity = new AlbumEntity(); const albumEntity = new AlbumEntity();
@ -56,25 +55,6 @@ describe('Album service', () => {
return albumEntity; return albumEntity;
}; };
const _getOwnedSharedAlbum = () => {
const albumEntity = new AlbumEntity();
albumEntity.ownerId = albumOwner.id;
albumEntity.owner = albumOwner;
albumEntity.id = albumId;
albumEntity.albumName = 'name';
albumEntity.createdAt = new Date('2022-06-19T23:41:36.910Z');
albumEntity.assets = [];
albumEntity.albumThumbnailAssetId = null;
albumEntity.sharedUsers = [
{
...userEntityStub.user1,
id: ownedAlbumSharedWithId,
},
];
return albumEntity;
};
const _getSharedWithAuthUserAlbum = () => { const _getSharedWithAuthUserAlbum = () => {
const albumEntity = new AlbumEntity(); const albumEntity = new AlbumEntity();
albumEntity.ownerId = sharedAlbumOwnerId; albumEntity.ownerId = sharedAlbumOwnerId;
@ -115,10 +95,8 @@ describe('Album service', () => {
beforeAll(() => { beforeAll(() => {
albumRepositoryMock = { albumRepositoryMock = {
addAssets: jest.fn(), addAssets: jest.fn(),
addSharedUsers: jest.fn(),
get: jest.fn(), get: jest.fn(),
removeAssets: jest.fn(), removeAssets: jest.fn(),
removeUser: jest.fn(),
updateThumbnails: jest.fn(), updateThumbnails: jest.fn(),
getCountByUserId: jest.fn(), getCountByUserId: jest.fn(),
getSharedWithUserAlbumCount: jest.fn(), getSharedWithUserAlbumCount: jest.fn(),
@ -188,53 +166,6 @@ describe('Album service', () => {
await expect(sut.get(authUser, '0002')).rejects.toBeInstanceOf(NotFoundException); await expect(sut.get(authUser, '0002')).rejects.toBeInstanceOf(NotFoundException);
}); });
it('removes a shared user from an owned album', async () => {
const albumEntity = _getOwnedSharedAlbum();
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.removeUser.mockImplementation(() => Promise.resolve());
await expect(sut.removeUser(authUser, albumEntity.id, ownedAlbumSharedWithId)).resolves.toBeUndefined();
expect(albumRepositoryMock.removeUser).toHaveBeenCalledTimes(1);
expect(albumRepositoryMock.removeUser).toHaveBeenCalledWith(albumEntity, ownedAlbumSharedWithId);
});
it('prevents removing a shared user from a not owned album (shared with auth user)', async () => {
const albumEntity = _getSharedWithAuthUserAlbum();
const albumId = albumEntity.id;
const userIdToRemove = sharedAlbumSharedAlsoWithId;
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
await expect(sut.removeUser(authUser, albumId, userIdToRemove)).rejects.toBeInstanceOf(ForbiddenException);
expect(albumRepositoryMock.removeUser).not.toHaveBeenCalled();
});
it('removes itself from a shared album', async () => {
const albumEntity = _getSharedWithAuthUserAlbum();
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.removeUser.mockImplementation(() => Promise.resolve());
await sut.removeUser(authUser, albumEntity.id, authUser.id);
expect(albumRepositoryMock.removeUser).toHaveReturnedTimes(1);
expect(albumRepositoryMock.removeUser).toHaveBeenCalledWith(albumEntity, authUser.id);
});
it('removes itself from a shared album using "me" as id', async () => {
const albumEntity = _getSharedWithAuthUserAlbum();
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.removeUser.mockImplementation(() => Promise.resolve());
await sut.removeUser(authUser, albumEntity.id, 'me');
expect(albumRepositoryMock.removeUser).toHaveReturnedTimes(1);
expect(albumRepositoryMock.removeUser).toHaveBeenCalledWith(albumEntity, authUser.id);
});
it('prevents removing itself from a owned album', async () => {
const albumEntity = _getOwnedAlbum();
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
await expect(sut.removeUser(authUser, albumEntity.id, authUser.id)).rejects.toBeInstanceOf(BadRequestException);
});
it('adds assets to owned album', async () => { it('adds assets to owned album', async () => {
const albumEntity = _getOwnedAlbum(); const albumEntity = _getOwnedAlbum();

View File

@ -1,7 +1,6 @@
import { BadRequestException, Inject, Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common'; import { BadRequestException, Inject, Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { AlbumEntity, SharedLinkType } from '@app/infra/entities'; import { AlbumEntity, SharedLinkType } from '@app/infra/entities';
import { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto'; import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { AlbumResponseDto, mapAlbum } from '@app/domain'; import { AlbumResponseDto, mapAlbum } from '@app/domain';
import { IAlbumRepository } from './album-repository'; import { IAlbumRepository } from './album-repository';
@ -63,24 +62,6 @@ export class AlbumService {
return mapAlbum(album); return mapAlbum(album);
} }
async addUsers(authUser: AuthUserDto, albumId: string, dto: AddUsersDto): Promise<AlbumResponseDto> {
const album = await this._getAlbum({ authUser, albumId });
const updatedAlbum = await this.albumRepository.addSharedUsers(album, dto);
return mapAlbum(updatedAlbum);
}
async removeUser(authUser: AuthUserDto, albumId: string, userId: string | 'me'): Promise<void> {
const sharedUserId = userId == 'me' ? authUser.id : userId;
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
if (album.ownerId != authUser.id && authUser.id != sharedUserId) {
throw new ForbiddenException('Cannot remove a user from a album that is not owned');
}
if (album.ownerId == sharedUserId) {
throw new BadRequestException('The owner of the album cannot be removed');
}
await this.albumRepository.removeUser(album, sharedUserId);
}
async removeAssets(authUser: AuthUserDto, albumId: string, dto: RemoveAssetsDto): Promise<AlbumResponseDto> { async removeAssets(authUser: AuthUserDto, albumId: string, dto: RemoveAssetsDto): Promise<AlbumResponseDto> {
const album = await this._getAlbum({ authUser, albumId }); const album = await this._getAlbum({ authUser, albumId });
const deletedCount = await this.albumRepository.removeAssets(album, dto); const deletedCount = await this.albumRepository.removeAssets(album, dto);

View File

@ -1,4 +1,4 @@
import { ValidateUUID } from 'apps/immich/src/decorators/validate-uuid.decorator'; import { ValidateUUID } from '../../../../../../apps/immich/src/decorators/validate-uuid.decorator';
export class AddUsersDto { export class AddUsersDto {
@ValidateUUID({ each: true }) @ValidateUUID({ each: true })

View File

@ -1,7 +1,8 @@
/* */ import { AlbumService, AuthUserDto, CreateAlbumDto, UpdateAlbumDto } from '@app/domain'; import { AddUsersDto, AlbumService, AuthUserDto, CreateAlbumDto, UpdateAlbumDto } from '@app/domain';
import { GetAlbumsDto } from '@app/domain/album/dto/get-albums.dto'; import { GetAlbumsDto } from '@app/domain/album/dto/get-albums.dto';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { ParseMeUUIDPipe } from '../api-v1/validation/parse-me-uuid-pipe';
import { GetAuthUser } from '../decorators/auth-user.decorator'; import { GetAuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator'; import { Authenticated } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator'; import { UseValidation } from '../decorators/use-validation.decorator';
@ -33,4 +34,18 @@ export class AlbumController {
deleteAlbum(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) { deleteAlbum(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
return this.service.delete(authUser, id); return this.service.delete(authUser, id);
} }
@Put(':id/users')
addUsersToAlbum(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: AddUsersDto) {
return this.service.addUsers(authUser, id, dto);
}
@Delete(':id/user/:userId')
removeUserFromAlbum(
@GetAuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Param('userId', new ParseMeUUIDPipe({ version: '4' })) userId: string,
) {
return this.service.removeUser(authUser, id, userId);
}
} }

View File

@ -228,6 +228,101 @@
] ]
} }
}, },
"/album/{id}/users": {
"put": {
"operationId": "addUsersToAlbum",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AddUsersDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AlbumResponseDto"
}
}
}
}
},
"tags": [
"Album"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/album/{id}/user/{userId}": {
"delete": {
"operationId": "removeUserFromAlbum",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "userId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Album"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/api-key": { "/api-key": {
"post": { "post": {
"operationId": "createKey", "operationId": "createKey",
@ -3990,58 +4085,6 @@
] ]
} }
}, },
"/album/{id}/users": {
"put": {
"operationId": "addUsersToAlbum",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AddUsersDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AlbumResponseDto"
}
}
}
}
},
"tags": [
"Album"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/album/{id}/assets": { "/album/{id}/assets": {
"put": { "put": {
"operationId": "addAssetsToAlbum", "operationId": "addAssetsToAlbum",
@ -4152,49 +4195,6 @@
] ]
} }
}, },
"/album/{id}/user/{userId}": {
"delete": {
"operationId": "removeUserFromAlbum",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "userId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Album"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/album/{id}/download": { "/album/{id}/download": {
"get": { "get": {
"operationId": "downloadArchive", "operationId": "downloadArchive",
@ -4778,6 +4778,21 @@
} }
} }
}, },
"AddUsersDto": {
"type": "object",
"properties": {
"sharedUserIds": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
}
},
"required": [
"sharedUserIds"
]
},
"APIKeyCreateDto": { "APIKeyCreateDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -6620,21 +6635,6 @@
"sharing" "sharing"
] ]
}, },
"AddUsersDto": {
"type": "object",
"properties": {
"sharedUserIds": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
}
},
"required": [
"sharedUserIds"
]
},
"AddAssetsResponseDto": { "AddAssetsResponseDto": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -1,7 +1,17 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common'; 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 { IAssetRepository } from '../asset';
import { IJobRepository, JobName } from '../job'; import { IJobRepository, JobName } from '../job';
import { IUserRepository } from '../user';
import { IAlbumRepository } from './album.repository'; import { IAlbumRepository } from './album.repository';
import { AlbumService } from './album.service'; import { AlbumService } from './album.service';
@ -10,13 +20,15 @@ describe(AlbumService.name, () => {
let albumMock: jest.Mocked<IAlbumRepository>; let albumMock: jest.Mocked<IAlbumRepository>;
let assetMock: jest.Mocked<IAssetRepository>; let assetMock: jest.Mocked<IAssetRepository>;
let jobMock: jest.Mocked<IJobRepository>; let jobMock: jest.Mocked<IJobRepository>;
let userMock: jest.Mocked<IUserRepository>;
beforeEach(async () => { beforeEach(async () => {
albumMock = newAlbumRepositoryMock(); albumMock = newAlbumRepositoryMock();
assetMock = newAssetRepositoryMock(); assetMock = newAssetRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
userMock = newUserRepositoryMock();
sut = new AlbumService(albumMock, assetMock, jobMock); sut = new AlbumService(albumMock, assetMock, jobMock, userMock);
}); });
it('should work', () => { it('should work', () => {
@ -152,6 +164,18 @@ describe(AlbumService.name, () => {
data: { ids: [albumStub.empty.id] }, 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', () => { describe('update', () => {
@ -240,4 +264,130 @@ describe(AlbumService.name, () => {
expect(albumMock.delete).toHaveBeenCalledWith(albumStub.empty); 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();
});
});
}); });

View File

@ -3,8 +3,9 @@ import { BadRequestException, ForbiddenException, Inject, Injectable } from '@ne
import { IAssetRepository, mapAsset } from '../asset'; import { IAssetRepository, mapAsset } from '../asset';
import { AuthUserDto } from '../auth'; import { AuthUserDto } from '../auth';
import { IJobRepository, JobName } from '../job'; import { IJobRepository, JobName } from '../job';
import { IUserRepository } from '../user';
import { IAlbumRepository } from './album.repository'; import { IAlbumRepository } from './album.repository';
import { CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto } from './dto'; import { AddUsersDto, CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto } from './dto';
import { AlbumResponseDto, mapAlbum } from './response-dto'; import { AlbumResponseDto, mapAlbum } from './response-dto';
@Injectable() @Injectable()
@ -13,6 +14,7 @@ export class AlbumService {
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository, @Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
) {} ) {}
async getAll({ id: ownerId }: AuthUserDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> { 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(); const invalidAlbumIds = await this.albumRepository.getInvalidThumbnail();
for (const albumId of invalidAlbumIds) { for (const albumId of invalidAlbumIds) {
@ -60,7 +62,13 @@ export class AlbumService {
} }
async create(authUser: AuthUserDto, dto: CreateAlbumDto): Promise<AlbumResponseDto> { 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({ const album = await this.albumRepository.create({
ownerId: authUser.id, ownerId: authUser.id,
albumName: dto.albumName, albumName: dto.albumName,
@ -68,19 +76,14 @@ export class AlbumService {
assets: (dto.assetIds || []).map((id) => ({ id } as AssetEntity)), assets: (dto.assetIds || []).map((id) => ({ id } as AssetEntity)),
albumThumbnailAssetId: dto.assetIds?.[0] || null, albumThumbnailAssetId: dto.assetIds?.[0] || null,
}); });
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [album.id] } }); await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [album.id] } });
return mapAlbum(album); return mapAlbum(album);
} }
async update(authUser: AuthUserDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> { async update(authUser: AuthUserDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
const [album] = await this.albumRepository.getByIds([id]); const album = await this.get(id);
if (!album) { this.assertOwner(authUser, album);
throw new BadRequestException('Album not found');
}
if (album.ownerId !== authUser.id) {
throw new ForbiddenException('Album not owned by user');
}
if (dto.albumThumbnailAssetId) { if (dto.albumThumbnailAssetId) {
const valid = await this.albumRepository.hasAsset(id, 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.albumRepository.delete(album);
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { ids: [id] } }); 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');
}
}
} }

View 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[];
}

View File

@ -1,3 +1,4 @@
export * from './album-add-users.dto';
export * from './album-create.dto'; export * from './album-create.dto';
export * from './album-update.dto'; export * from './album-update.dto';
export * from './get-albums.dto'; export * from './get-albums.dto';

View File

@ -61,6 +61,16 @@ export const authStub = {
isShowExif: true, isShowExif: true,
accessTokenId: 'token-id', 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>({ adminSharedLink: Object.freeze<AuthUserDto>({
id: 'admin_id', id: 'admin_id',
email: 'admin@test.com', email: 'admin@test.com',
@ -125,6 +135,21 @@ export const userEntityStub = {
tags: [], tags: [],
assets: [], 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>({ storageLabel: Object.freeze<UserEntity>({
...authStub.user1, ...authStub.user1,
password: 'immich_password', password: 'immich_password',
@ -357,6 +382,19 @@ export const albumStub = {
sharedLinks: [], sharedLinks: [],
sharedUsers: [userEntityStub.user1], 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>({ sharedWithAdmin: Object.freeze<AlbumEntity>({
id: 'album-3', id: 'album-3',
albumName: 'Empty album shared with admin', albumName: 'Empty album shared with admin',

View File

@ -16,6 +16,7 @@ export class AlbumRepository implements IAlbumRepository {
}, },
relations: { relations: {
owner: true, owner: true,
sharedUsers: true,
}, },
}); });
} }
@ -153,6 +154,12 @@ export class AlbumRepository implements IAlbumRepository {
private async save(album: Partial<AlbumEntity>) { private async save(album: Partial<AlbumEntity>) {
const { id } = await this.repository.save(album); 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,
},
});
} }
} }

View File

@ -42,6 +42,7 @@
import ShareInfoModal from './share-info-modal.svelte'; import ShareInfoModal from './share-info-modal.svelte';
import ThumbnailSelection from './thumbnail-selection.svelte'; import ThumbnailSelection from './thumbnail-selection.svelte';
import UserSelectionModal from './user-selection-modal.svelte'; import UserSelectionModal from './user-selection-modal.svelte';
import { handleError } from '../../utils/handle-error';
export let album: AlbumResponseDto; export let album: AlbumResponseDto;
export let sharedLink: SharedLinkResponseDto | undefined = undefined; export let sharedLink: SharedLinkResponseDto | undefined = undefined;
@ -195,19 +196,16 @@
if (userId == 'me') { if (userId == 'me') {
isShowShareInfoModal = false; isShowShareInfoModal = false;
goto(backUrl); goto(backUrl);
return;
} }
try { try {
const { data } = await api.albumApi.getAlbumInfo({ id: album.id }); const { data } = await api.albumApi.getAlbumInfo({ id: album.id });
album = data; album = data;
isShowShareInfoModal = false; isShowShareInfoModal = data.sharedUsers.length >= 1;
} catch (e) { } catch (e) {
console.error('Error [sharedUserDeletedHandler] ', e); handleError(e, 'Error deleting share users');
notificationController.show({
type: NotificationType.Error,
message: 'Error deleting share users, check console for more details'
});
} }
}; };

View File

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher, onMount } from 'svelte'; import { createEventDispatcher, onMount } from 'svelte';
import { AlbumResponseDto, api, UserResponseDto } from '@api'; import { AlbumResponseDto, api, UserResponseDto } from '@api';
import { clickOutside } from '$lib/utils/click-outside';
import BaseModal from '../shared-components/base-modal.svelte'; import BaseModal from '../shared-components/base-modal.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte'; import UserAvatar from '../shared-components/user-avatar.svelte';
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte'; import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
@ -12,15 +11,18 @@
notificationController, notificationController,
NotificationType NotificationType
} from '../shared-components/notification/notification'; } from '../shared-components/notification/notification';
import { handleError } from '../../utils/handle-error';
import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
export let album: AlbumResponseDto; export let album: AlbumResponseDto;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let currentUser: UserResponseDto; let currentUser: UserResponseDto;
let isShowMenu = false;
let position = { x: 0, y: 0 }; let position = { x: 0, y: 0 };
let targetUserId: string; let selectedMenuUser: UserResponseDto | null = null;
let selectedRemoveUser: UserResponseDto | null = null;
$: isOwned = currentUser?.id == album.ownerId; $: isOwned = currentUser?.id == album.ownerId;
onMount(async () => { onMount(async () => {
@ -28,16 +30,12 @@
const { data } = await api.userApi.getMyUserInfo(); const { data } = await api.userApi.getMyUserInfo();
currentUser = data; currentUser = data;
} catch (e) { } catch (e) {
console.error('Error [share-info-modal] [getAllUsers]', e); handleError(e, 'Unable to refresh user');
notificationController.show({
message: 'Error getting user info, check console for more details',
type: NotificationType.Error
});
} }
}); });
const showContextMenu = (userId: string) => { const showContextMenu = (user: UserResponseDto) => {
const iconButton = document.getElementById('icon-' + userId); const iconButton = document.getElementById('icon-' + user.id);
if (iconButton) { if (iconButton) {
position = { position = {
@ -46,69 +44,101 @@
}; };
} }
targetUserId = userId; selectedMenuUser = user;
isShowMenu = !isShowMenu; selectedRemoveUser = null;
}; };
const removeUser = async (userId: string) => { const handleMenuRemove = () => {
if (window.confirm('Do you want to remove selected user from the album?')) { selectedRemoveUser = selectedMenuUser;
try { selectedMenuUser = null;
await api.albumApi.removeUserFromAlbum({ id: album.id, userId }); };
dispatch('user-deleted', { userId });
} catch (e) { const handleRemoveUser = async () => {
console.error('Error [share-info-modal] [removeUser]', e); if (!selectedRemoveUser) {
notificationController.show({ return;
message: 'Error removing user, check console for more details', }
type: NotificationType.Error
}); const userId = selectedRemoveUser.id === currentUser?.id ? 'me' : selectedRemoveUser.id;
}
try {
await api.albumApi.removeUserFromAlbum({ id: album.id, userId });
dispatch('user-deleted', { userId });
const message =
userId === 'me' ? `Left ${album.albumName}` : `Removed ${selectedRemoveUser.firstName}`;
notificationController.show({ type: NotificationType.Info, message });
} catch (e) {
handleError(e, 'Unable to remove user');
} finally {
selectedRemoveUser = null;
} }
}; };
</script> </script>
<BaseModal on:close={() => dispatch('close')}> {#if !selectedRemoveUser}
<svelte:fragment slot="title"> <BaseModal on:close={() => dispatch('close')}>
<span class="flex gap-2 place-items-center"> <svelte:fragment slot="title">
<p class="font-medium text-immich-fg dark:text-immich-dark-fg">Options</p> <span class="flex gap-2 place-items-center">
</span> <p class="font-medium text-immich-fg dark:text-immich-dark-fg">Options</p>
</svelte:fragment> </span>
</svelte:fragment>
<section class="max-h-[400px] overflow-y-auto immich-scrollbar pb-4"> <section class="max-h-[400px] overflow-y-auto immich-scrollbar pb-4">
{#each album.sharedUsers as user} {#each album.sharedUsers as user}
<div <div
class="flex gap-4 p-5 place-items-center justify-between w-full transition-colors hover:bg-gray-50 dark:hover:bg-gray-700" class="flex gap-4 p-5 place-items-center justify-between w-full transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
> >
<div class="flex gap-4 place-items-center"> <div class="flex gap-4 place-items-center">
<UserAvatar {user} size="md" autoColor /> <UserAvatar {user} size="md" autoColor />
<p class="font-medium text-sm">{user.firstName} {user.lastName}</p> <p class="font-medium text-sm">{user.firstName} {user.lastName}</p>
</div> </div>
<div id={`icon-${user.id}`} class="flex place-items-center"> <div id={`icon-${user.id}`} class="flex place-items-center">
{#if isOwned} {#if isOwned}
<div use:clickOutside on:outclick={() => (isShowMenu = false)}> <div>
<CircleIconButton <CircleIconButton
on:click={() => showContextMenu(user.id)} on:click={() => showContextMenu(user)}
logo={DotsVertical} logo={DotsVertical}
backgroundColor={'transparent'} backgroundColor="transparent"
hoverColor={'#e2e7e9'} hoverColor="#e2e7e9"
size={'20'} size="20"
> />
{#if isShowMenu}
<ContextMenu {...position}> {#if selectedMenuUser === user}
<MenuOption on:click={() => removeUser(targetUserId)} text="Remove" /> <ContextMenu {...position} on:outclick={() => (selectedMenuUser = null)}>
<MenuOption on:click={handleMenuRemove} text="Remove" />
</ContextMenu> </ContextMenu>
{/if} {/if}
</CircleIconButton> </div>
</div> {:else if user.id == currentUser?.id}
{:else if user.id == currentUser?.id} <button
<button on:click={() => (selectedRemoveUser = user)}
on:click={() => removeUser('me')} class="text-sm text-immich-primary dark:text-immich-dark-primary font-medium transition-colors hover:text-immich-primary/75"
class="text-sm text-immich-primary dark:text-immich-dark-primary font-medium transition-colors hover:text-immich-primary/75" >Leave</button
>Leave</button >
> {/if}
{/if} </div>
</div> </div>
</div> {/each}
{/each} </section>
</section> </BaseModal>
</BaseModal> {/if}
{#if selectedRemoveUser && selectedRemoveUser?.id === currentUser?.id}
<ConfirmDialogue
title="Leave Album?"
prompt="Are you sure you want to leave {album.albumName}?"
confirmText="Leave"
on:confirm={handleRemoveUser}
on:cancel={() => (selectedRemoveUser = null)}
/>
{/if}
{#if selectedRemoveUser && selectedRemoveUser?.id !== currentUser?.id}
<ConfirmDialogue
title="Remove User?"
prompt="Are you sure you want to remove {selectedRemoveUser.firstName} {selectedRemoveUser.lastName}"
confirmText="Remove"
on:confirm={handleRemoveUser}
on:cancel={() => (selectedRemoveUser = null)}
/>
{/if}