1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat(web,server): user storage label (#2418)

* feat: user storage label

* chore: open api

* fix: checks

* fix: api update validation and tests

* feat: default admin storage label

* fix: linting

* fix: user create/update dto

* fix: delete library with custom label
This commit is contained in:
Jason Rasmussen 2023-05-21 23:18:10 -04:00 committed by GitHub
parent 0ccb73cf2b
commit 74353193f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 380 additions and 123 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -39,6 +39,7 @@ describe('Album service', () => {
oauthId: '',
tags: [],
assets: [],
storageLabel: null,
});
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
const sharedAlbumOwnerId = '2222';

View File

@ -27,6 +27,7 @@ describe('TagService', () => {
tags: [],
assets: [],
oauthId: 'oauth-id-1',
storageLabel: null,
});
// const user2: UserEntity = Object.freeze({

View File

@ -1,4 +1,10 @@
export const toBoolean = ({ value }: { value: string }) => {
import sanitize from 'sanitize-filename';
interface IValue {
value?: string;
}
export const toBoolean = ({ value }: IValue) => {
if (value == 'true') {
return true;
} else if (value == 'false') {
@ -6,3 +12,7 @@ export const toBoolean = ({ value }: { value: string }) => {
}
return value;
};
export const toEmail = ({ value }: IValue) => value?.toLowerCase();
export const toSanitized = ({ value }: IValue) => sanitize((value || '').replace(/\./g, ''));

View File

@ -87,10 +87,10 @@ describe('User', () => {
]);
});
it('fetches the user collection excluding the auth user', async () => {
it('fetches the user collection including the auth user', async () => {
const { status, body } = await request(app.getHttpServer()).get('/user?isAll=false');
expect(status).toEqual(200);
expect(body).toHaveLength(2);
expect(body).toHaveLength(3);
expect(body).toEqual(
expect.arrayContaining([
{
@ -105,6 +105,7 @@ describe('User', () => {
deletedAt: null,
updatedAt: expect.anything(),
oauthId: '',
storageLabel: null,
},
{
email: userTwoEmail,
@ -118,10 +119,24 @@ describe('User', () => {
deletedAt: null,
updatedAt: expect.anything(),
oauthId: '',
storageLabel: null,
},
{
email: authUserEmail,
firstName: 'auth-user',
lastName: 'test',
id: expect.anything(),
createdAt: expect.anything(),
isAdmin: true,
shouldChangePassword: true,
profileImagePath: '',
deletedAt: null,
updatedAt: expect.anything(),
oauthId: '',
storageLabel: 'admin',
},
]),
);
expect(body).toEqual(expect.not.arrayContaining([expect.objectContaining({ email: authUserEmail })]));
});
it('disallows admin user from creating a second admin account', async () => {

View File

@ -4122,6 +4122,10 @@
"lastName": {
"type": "string"
},
"storageLabel": {
"type": "string",
"nullable": true
},
"createdAt": {
"type": "string"
},
@ -4150,6 +4154,7 @@
"email",
"firstName",
"lastName",
"storageLabel",
"createdAt",
"profileImagePath",
"shouldChangePassword",
@ -5529,20 +5534,20 @@
"type": "object",
"properties": {
"email": {
"type": "string",
"example": "testuser@email.com"
"type": "string"
},
"password": {
"type": "string",
"example": "password"
"type": "string"
},
"firstName": {
"type": "string",
"example": "John"
"type": "string"
},
"lastName": {
"type": "string"
},
"storageLabel": {
"type": "string",
"example": "Doe"
"nullable": true
}
},
"required": [
@ -5566,26 +5571,25 @@
"UpdateUserDto": {
"type": "object",
"properties": {
"email": {
"type": "string",
"example": "testuser@email.com"
},
"password": {
"type": "string",
"example": "password"
},
"firstName": {
"type": "string",
"example": "John"
},
"lastName": {
"type": "string",
"example": "Doe"
},
"id": {
"type": "string",
"format": "uuid"
},
"email": {
"type": "string"
},
"password": {
"type": "string"
},
"firstName": {
"type": "string"
},
"lastName": {
"type": "string"
},
"storageLabel": {
"type": "string"
},
"isAdmin": {
"type": "boolean"
},

View File

@ -306,7 +306,7 @@ describe('AuthService', () => {
expect(userTokenMock.save.mock.calls[0][0]).toMatchObject({
id: 'not_active',
token: 'auth_token',
userId: 'immich_id',
userId: 'user-id',
createdAt: new Date('2021-01-01'),
updatedAt: expect.any(Date),
deviceOS: 'Android',

View File

@ -122,6 +122,7 @@ export class AuthService {
firstName: dto.firstName,
lastName: dto.lastName,
password: dto.password,
storageLabel: 'admin',
});
return mapAdminSignupResponse(admin);

View File

@ -17,19 +17,21 @@ const responseDto = {
profileImagePath: '',
shouldChangePassword: false,
updatedAt: '2021-01-01',
storageLabel: 'admin',
},
user1: {
createdAt: '2021-01-01',
deletedAt: undefined,
email: 'immich@test.com',
firstName: 'immich_first_name',
id: 'immich_id',
id: 'user-id',
isAdmin: false,
lastName: 'immich_last_name',
oauthId: '',
profileImagePath: '',
shouldChangePassword: false,
updatedAt: '2021-01-01',
storageLabel: null,
},
};

View File

@ -4,7 +4,7 @@ import handlebar from 'handlebars';
import * as luxon from 'luxon';
import path from 'node:path';
import sanitize from 'sanitize-filename';
import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
import { IStorageRepository, StorageCore } from '../storage';
import {
ISystemConfigRepository,
supportedDayTokens,
@ -15,6 +15,7 @@ import {
supportedYearTokens,
} from '../system-config';
import { SystemConfigCore } from '../system-config/system-config.core';
import { MoveAssetMetadata } from './storage-template.service';
export class StorageTemplateCore {
private logger = new Logger(StorageTemplateCore.name);
@ -33,12 +34,14 @@ export class StorageTemplateCore {
this.configCore.config$.subscribe((config) => this.onConfig(config));
}
public async getTemplatePath(asset: AssetEntity, filename: string): Promise<string> {
public async getTemplatePath(asset: AssetEntity, metadata: MoveAssetMetadata): Promise<string> {
const { storageLabel, filename } = metadata;
try {
const source = asset.originalPath;
const ext = path.extname(source).split('.').pop() as string;
const sanitized = sanitize(path.basename(filename, `.${ext}`));
const rootPath = this.storageCore.getFolderLocation(StorageFolder.LIBRARY, asset.ownerId);
const rootPath = this.storageCore.getLibraryFolder({ id: asset.ownerId, storageLabel });
const storagePath = this.render(this.storageTemplate, asset, sanitized, ext);
const fullPath = path.normalize(path.join(rootPath, storagePath));
let destination = `${fullPath}.${ext}`;

View File

@ -4,18 +4,22 @@ import {
newAssetRepositoryMock,
newStorageRepositoryMock,
newSystemConfigRepositoryMock,
newUserRepositoryMock,
systemConfigStub,
userEntityStub,
} from '../../test';
import { IAssetRepository } from '../asset';
import { StorageTemplateService } from '../storage-template';
import { IStorageRepository } from '../storage/storage.repository';
import { ISystemConfigRepository } from '../system-config';
import { IUserRepository } from '../user';
describe(StorageTemplateService.name, () => {
let sut: StorageTemplateService;
let assetMock: jest.Mocked<IAssetRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
let userMock: jest.Mocked<IUserRepository>;
it('should work', () => {
expect(sut).toBeDefined();
@ -25,12 +29,15 @@ describe(StorageTemplateService.name, () => {
assetMock = newAssetRepositoryMock();
configMock = newSystemConfigRepositoryMock();
storageMock = newStorageRepositoryMock();
sut = new StorageTemplateService(assetMock, configMock, systemConfigStub.defaults, storageMock);
userMock = newUserRepositoryMock();
sut = new StorageTemplateService(assetMock, configMock, systemConfigStub.defaults, storageMock, userMock);
});
describe('handle template migration', () => {
it('should handle no assets', async () => {
assetMock.getAll.mockResolvedValue([]);
userMock.getList.mockResolvedValue([]);
await sut.handleTemplateMigration();
@ -40,6 +47,7 @@ describe(StorageTemplateService.name, () => {
it('should handle an asset with a duplicate destination', async () => {
assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
assetMock.save.mockResolvedValue(assetEntityStub.image);
userMock.getList.mockResolvedValue([userEntityStub.user1]);
when(storageMock.checkFileExists)
.calledWith('upload/library/user-id/2023/2023-02-23/asset-id.ext')
@ -57,6 +65,7 @@ describe(StorageTemplateService.name, () => {
id: assetEntityStub.image.id,
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext',
});
expect(userMock.getList).toHaveBeenCalled();
});
it('should skip when an asset already matches the template', async () => {
@ -66,6 +75,7 @@ describe(StorageTemplateService.name, () => {
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.ext',
},
]);
userMock.getList.mockResolvedValue([userEntityStub.user1]);
await sut.handleTemplateMigration();
@ -82,6 +92,7 @@ describe(StorageTemplateService.name, () => {
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext',
},
]);
userMock.getList.mockResolvedValue([userEntityStub.user1]);
await sut.handleTemplateMigration();
@ -94,6 +105,7 @@ describe(StorageTemplateService.name, () => {
it('should move an asset', async () => {
assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
assetMock.save.mockResolvedValue(assetEntityStub.image);
userMock.getList.mockResolvedValue([userEntityStub.user1]);
await sut.handleTemplateMigration();
@ -108,9 +120,28 @@ describe(StorageTemplateService.name, () => {
});
});
it('should use the user storage label', async () => {
assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
assetMock.save.mockResolvedValue(assetEntityStub.image);
userMock.getList.mockResolvedValue([userEntityStub.storageLabel]);
await sut.handleTemplateMigration();
expect(assetMock.getAll).toHaveBeenCalled();
expect(storageMock.moveFile).toHaveBeenCalledWith(
'/original/path.ext',
'upload/library/label-1/2023/2023-02-23/asset-id.ext',
);
expect(assetMock.save).toHaveBeenCalledWith({
id: assetEntityStub.image.id,
originalPath: 'upload/library/label-1/2023/2023-02-23/asset-id.ext',
});
});
it('should not update the database if the move fails', async () => {
assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
storageMock.moveFile.mockRejectedValue(new Error('Read only system'));
userMock.getList.mockResolvedValue([userEntityStub.user1]);
await sut.handleTemplateMigration();
@ -125,6 +156,7 @@ describe(StorageTemplateService.name, () => {
it('should move the asset back if the database fails', async () => {
assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
assetMock.save.mockRejectedValue('Connection Error!');
userMock.getList.mockResolvedValue([userEntityStub.user1]);
await sut.handleTemplateMigration();
@ -143,6 +175,7 @@ describe(StorageTemplateService.name, () => {
it('should handle an error', async () => {
assetMock.getAll.mockResolvedValue([]);
storageMock.removeEmptyDirs.mockRejectedValue(new Error('Read only filesystem'));
userMock.getList.mockResolvedValue([]);
await sut.handleTemplateMigration();
});

View File

@ -6,8 +6,14 @@ import { getLivePhotoMotionFilename } from '../domain.util';
import { IAssetJob } from '../job';
import { IStorageRepository } from '../storage/storage.repository';
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
import { IUserRepository } from '../user/user.repository';
import { StorageTemplateCore } from './storage-template.core';
export interface MoveAssetMetadata {
storageLabel: string | null;
filename: string;
}
@Injectable()
export class StorageTemplateService {
private logger = new Logger(StorageTemplateService.name);
@ -18,6 +24,7 @@ export class StorageTemplateService {
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
) {
this.core = new StorageTemplateCore(configRepository, config, storageRepository);
}
@ -26,14 +33,16 @@ export class StorageTemplateService {
const { asset } = data;
try {
const user = await this.userRepository.get(asset.ownerId);
const storageLabel = user?.storageLabel || null;
const filename = asset.originalFileName || asset.id;
await this.moveAsset(asset, filename);
await this.moveAsset(asset, { storageLabel, filename });
// move motion part of live photo
if (asset.livePhotoVideoId) {
const [livePhotoVideo] = await this.assetRepository.getByIds([asset.livePhotoVideoId]);
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
await this.moveAsset(livePhotoVideo, motionFilename);
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
}
} catch (error: any) {
this.logger.error('Error running single template migration', error);
@ -44,6 +53,7 @@ export class StorageTemplateService {
try {
console.time('migrating-time');
const assets = await this.assetRepository.getAll();
const users = await this.userRepository.getList();
const livePhotoMap: Record<string, AssetEntity> = {};
@ -56,8 +66,10 @@ export class StorageTemplateService {
for (const asset of assets) {
const livePhotoParentAsset = livePhotoMap[asset.id];
// TODO: remove livePhoto specific stuff once upload is fixed
const user = users.find((user) => user.id === asset.ownerId);
const storageLabel = user?.storageLabel || null;
const filename = asset.originalFileName || livePhotoParentAsset?.originalFileName || asset.id;
await this.moveAsset(asset, filename);
await this.moveAsset(asset, { storageLabel, filename });
}
this.logger.debug('Cleaning up empty directories...');
@ -70,8 +82,8 @@ export class StorageTemplateService {
}
// TODO: use asset core (once in domain)
async moveAsset(asset: AssetEntity, originalName: string) {
const destination = await this.core.getTemplatePath(asset, originalName);
async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) {
const destination = await this.core.getTemplatePath(asset, metadata);
if (asset.originalPath !== destination) {
const source = asset.originalPath;

View File

@ -10,7 +10,14 @@ export enum StorageFolder {
}
export class StorageCore {
getFolderLocation(folder: StorageFolder, userId: string) {
getFolderLocation(
folder: StorageFolder.ENCODED_VIDEO | StorageFolder.UPLOAD | StorageFolder.PROFILE | StorageFolder.THUMBNAILS,
userId: string,
) {
return join(APP_MEDIA_LOCATION, folder, userId);
}
getLibraryFolder(user: { storageLabel: string | null; id: string }) {
return join(APP_MEDIA_LOCATION, StorageFolder.LIBRARY, user.storageLabel || user.id);
}
}

View File

@ -1,24 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsNotEmpty, IsEmail } from 'class-validator';
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { toEmail, toSanitized } from '../../../../../apps/immich/src/utils/transform.util';
export class CreateUserDto {
@IsEmail()
@Transform(({ value }) => value?.toLowerCase())
@ApiProperty({ example: 'testuser@email.com' })
@Transform(toEmail)
email!: string;
@IsNotEmpty()
@ApiProperty({ example: 'password' })
@IsString()
password!: string;
@IsNotEmpty()
@ApiProperty({ example: 'John' })
@IsString()
firstName!: string;
@IsNotEmpty()
@ApiProperty({ example: 'Doe' })
@IsString()
lastName!: string;
@IsOptional()
@IsString()
@Transform(toSanitized)
storageLabel?: string | null;
}
export class CreateAdminDto {

View File

@ -1,8 +1,34 @@
import { IsBoolean, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
import { CreateUserDto } from './create-user.dto';
import { ApiProperty, PartialType } from '@nestjs/swagger';
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
import { toEmail, toSanitized } from '../../../../../apps/immich/src/utils/transform.util';
export class UpdateUserDto {
@IsOptional()
@IsEmail()
@Transform(toEmail)
email?: string;
@IsOptional()
@IsNotEmpty()
@IsString()
password?: string;
@IsOptional()
@IsString()
@IsNotEmpty()
firstName?: string;
@IsOptional()
@IsString()
@IsNotEmpty()
lastName?: string;
@IsOptional()
@IsString()
@Transform(toSanitized)
storageLabel?: string;
export class UpdateUserDto extends PartialType(CreateUserDto) {
@IsNotEmpty()
@IsUUID('4')
@ApiProperty({ format: 'uuid' })

View File

@ -5,6 +5,7 @@ export class UserResponseDto {
email!: string;
firstName!: string;
lastName!: string;
storageLabel!: string | null;
createdAt!: string;
profileImagePath!: string;
shouldChangePassword!: boolean;
@ -20,6 +21,7 @@ export function mapUser(entity: UserEntity): UserResponseDto {
email: entity.email,
firstName: entity.firstName,
lastName: entity.lastName,
storageLabel: entity.storageLabel,
createdAt: entity.createdAt,
profileImagePath: entity.profileImagePath,
shouldChangePassword: entity.shouldChangePassword,

View File

@ -5,7 +5,6 @@ import {
InternalServerErrorException,
Logger,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { hash } from 'bcrypt';
import { constants, createReadStream, ReadStream } from 'fs';
@ -28,6 +27,7 @@ export class UserCore {
if (!authUser.isAdmin) {
// Users can never update the isAdmin property.
delete dto.isAdmin;
delete dto.storageLabel;
} else if (dto.isAdmin && authUser.id !== id) {
// Admin cannot create another admin.
throw new BadRequestException('The server already has an admin');
@ -36,7 +36,14 @@ export class UserCore {
if (dto.email) {
const duplicate = await this.userRepository.getByEmail(dto.email);
if (duplicate && duplicate.id !== id) {
throw new BadRequestException('Email already in user by another account');
throw new BadRequestException('Email already in use by another account');
}
}
if (dto.storageLabel) {
const duplicate = await this.userRepository.getByStorageLabel(dto.storageLabel);
if (duplicate && duplicate.id !== id) {
throw new BadRequestException('Storage label already in use by another account');
}
}
@ -45,6 +52,10 @@ export class UserCore {
dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS);
}
if (dto.storageLabel === '') {
dto.storageLabel = null;
}
return this.userRepository.update(id, dto);
} catch (e) {
Logger.error(e, 'Failed to update user info');
@ -106,14 +117,8 @@ export class UserCore {
}
async createProfileImage(authUser: AuthUserDto, filePath: string): Promise<UserEntity> {
// TODO: do we need to do this? Maybe we can trust the authUser
const user = await this.userRepository.get(authUser.id);
if (!user) {
throw new NotFoundException('User not found');
}
try {
return this.userRepository.update(user.id, { profileImagePath: filePath });
return this.userRepository.update(authUser.id, { profileImagePath: filePath });
} catch (e) {
Logger.error(e, 'Create User Profile Image');
throw new InternalServerErrorException('Failed to create new user profile image');
@ -121,12 +126,7 @@ export class UserCore {
}
async restoreUser(authUser: AuthUserDto, userToRestore: UserEntity): Promise<UserEntity> {
// TODO: do we need to do this? Maybe we can trust the authUser
const requestor = await this.userRepository.get(authUser.id);
if (!requestor) {
throw new UnauthorizedException('Requestor not found');
}
if (!requestor.isAdmin) {
if (!authUser.isAdmin) {
throw new ForbiddenException('Unauthorized');
}
try {
@ -138,12 +138,7 @@ export class UserCore {
}
async deleteUser(authUser: AuthUserDto, userToDelete: UserEntity): Promise<UserEntity> {
// TODO: do we need to do this? Maybe we can trust the authUser
const requestor = await this.userRepository.get(authUser.id);
if (!requestor) {
throw new UnauthorizedException('Requestor not found');
}
if (!requestor.isAdmin) {
if (!authUser.isAdmin) {
throw new ForbiddenException('Unauthorized');
}

View File

@ -1,7 +1,7 @@
import { UserEntity } from '@app/infra/entities';
export interface UserListFilter {
excludeId?: string;
withDeleted?: boolean;
}
export interface UserStatsQueryResponse {
@ -19,6 +19,7 @@ export interface IUserRepository {
get(id: string, withDeleted?: boolean): Promise<UserEntity | null>;
getAdmin(): Promise<UserEntity | null>;
getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null>;
getByStorageLabel(storageLabel: string): Promise<UserEntity | null>;
getByOAuthId(oauthId: string): Promise<UserEntity | null>;
getDeletedUsers(): Promise<UserEntity[]>;
getList(filter?: UserListFilter): Promise<UserEntity[]>;

View File

@ -36,7 +36,7 @@ const adminUserAuth: AuthUserDto = Object.freeze({
});
const immichUserAuth: AuthUserDto = Object.freeze({
id: 'immich_id',
id: 'user-id',
email: 'immich@test.com',
isAdmin: false,
});
@ -55,6 +55,7 @@ const adminUser: UserEntity = Object.freeze({
updatedAt: '2021-01-01',
tags: [],
assets: [],
storageLabel: 'admin',
});
const immichUser: UserEntity = Object.freeze({
@ -71,6 +72,7 @@ const immichUser: UserEntity = Object.freeze({
updatedAt: '2021-01-01',
tags: [],
assets: [],
storageLabel: null,
});
const updatedImmichUser: UserEntity = Object.freeze({
@ -87,6 +89,7 @@ const updatedImmichUser: UserEntity = Object.freeze({
updatedAt: '2021-01-01',
tags: [],
assets: [],
storageLabel: null,
});
const adminUserResponse = Object.freeze({
@ -101,6 +104,7 @@ const adminUserResponse = Object.freeze({
profileImagePath: '',
createdAt: '2021-01-01',
updatedAt: '2021-01-01',
storageLabel: 'admin',
});
describe(UserService.name, () => {
@ -150,7 +154,7 @@ describe(UserService.name, () => {
const response = await sut.getAllUsers(adminUserAuth, false);
expect(userRepositoryMock.getList).toHaveBeenCalledWith({ excludeId: adminUser.id });
expect(userRepositoryMock.getList).toHaveBeenCalledWith({ withDeleted: true });
expect(response).toEqual([
{
id: adminUserAuth.id,
@ -164,6 +168,7 @@ describe(UserService.name, () => {
profileImagePath: '',
createdAt: '2021-01-01',
updatedAt: '2021-01-01',
storageLabel: 'admin',
},
]);
});
@ -231,6 +236,22 @@ describe(UserService.name, () => {
expect(updatedUser.shouldChangePassword).toEqual(true);
});
it('should not set an empty string for storage label', async () => {
userRepositoryMock.update.mockResolvedValue(updatedImmichUser);
await sut.updateUser(adminUserAuth, { id: immichUser.id, storageLabel: '' });
expect(userRepositoryMock.update).toHaveBeenCalledWith(immichUser.id, { id: immichUser.id, storageLabel: null });
});
it('should omit a storage label set by non-admin users', async () => {
userRepositoryMock.update.mockResolvedValue(updatedImmichUser);
await sut.updateUser(immichUserAuth, { id: immichUser.id, storageLabel: 'admin' });
expect(userRepositoryMock.update).toHaveBeenCalledWith(immichUser.id, { id: immichUser.id });
});
it('user can only update its information', async () => {
when(userRepositoryMock.get)
.calledWith('not_immich_auth_user_id', undefined)
@ -255,7 +276,7 @@ describe(UserService.name, () => {
await sut.updateUser(immichUser, dto);
expect(userRepositoryMock.update).toHaveBeenCalledWith(immichUser.id, {
id: 'immich_id',
id: 'user-id',
email: 'updated@test.com',
});
});
@ -271,6 +292,17 @@ describe(UserService.name, () => {
expect(userRepositoryMock.update).not.toHaveBeenCalled();
});
it('should not let the admin change the storage label to one already in use', async () => {
const dto = { id: immichUser.id, storageLabel: 'admin' };
userRepositoryMock.get.mockResolvedValue(immichUser);
userRepositoryMock.getByStorageLabel.mockResolvedValue(adminUser);
await expect(sut.updateUser(adminUser, dto)).rejects.toBeInstanceOf(BadRequestException);
expect(userRepositoryMock.update).not.toHaveBeenCalled();
});
it('admin can update any user information', async () => {
const update: UpdateUserDto = {
id: immichUser.id,
@ -481,6 +513,16 @@ describe(UserService.name, () => {
expect(userRepositoryMock.delete).toHaveBeenCalledWith(user, true);
});
it('should delete the library path for a storage label', async () => {
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10), storageLabel: 'admin' } as UserEntity;
await sut.handleUserDelete({ user });
const options = { force: true, recursive: true };
expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/library/admin', options);
});
it('should handle an error', async () => {
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserEntity;

View File

@ -44,13 +44,8 @@ export class UserService {
}
async getAllUsers(authUser: AuthUserDto, isAll: boolean): Promise<UserResponseDto[]> {
if (isAll) {
const allUsers = await this.userCore.getList();
return allUsers.map(mapUser);
}
const allUserExceptRequestedUser = await this.userCore.getList({ excludeId: authUser.id });
return allUserExceptRequestedUser.map(mapUser);
const users = await this.userCore.getList({ withDeleted: !isAll });
return users.map(mapUser);
}
async getUserById(userId: string, withDeleted = false): Promise<UserResponseDto> {
@ -165,7 +160,7 @@ export class UserService {
try {
const folders = [
this.storageCore.getFolderLocation(StorageFolder.LIBRARY, user.id),
this.storageCore.getLibraryFolder(user),
this.storageCore.getFolderLocation(StorageFolder.UPLOAD, user.id),
this.storageCore.getFolderLocation(StorageFolder.PROFILE, user.id),
this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, user.id),

View File

@ -43,7 +43,7 @@ export const authStub = {
isAllowUpload: true,
}),
user1: Object.freeze<AuthUserDto>({
id: 'immich_id',
id: 'user-id',
email: 'immich@test.com',
isAdmin: false,
isPublicUser: false,
@ -81,6 +81,7 @@ export const userEntityStub = {
password: 'admin_password',
firstName: 'admin_first_name',
lastName: 'admin_last_name',
storageLabel: 'admin',
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
@ -94,6 +95,21 @@ export const userEntityStub = {
password: 'immich_password',
firstName: 'immich_first_name',
lastName: 'immich_last_name',
storageLabel: null,
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
createdAt: '2021-01-01',
updatedAt: '2021-01-01',
tags: [],
assets: [],
}),
storageLabel: Object.freeze<UserEntity>({
...authStub.user1,
password: 'immich_password',
firstName: 'immich_first_name',
lastName: 'immich_last_name',
storageLabel: 'label-1',
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
@ -536,7 +552,7 @@ export const loginResponseStub = {
user1oauth: {
response: {
accessToken: 'cmFuZG9tLWJ5dGVz',
userId: 'immich_id',
userId: 'user-id',
userEmail: 'immich@test.com',
firstName: 'immich_first_name',
lastName: 'immich_last_name',
@ -552,7 +568,7 @@ export const loginResponseStub = {
user1password: {
response: {
accessToken: 'cmFuZG9tLWJ5dGVz',
userId: 'immich_id',
userId: 'user-id',
userEmail: 'immich@test.com',
firstName: 'immich_first_name',
lastName: 'immich_last_name',
@ -568,7 +584,7 @@ export const loginResponseStub = {
user1insecure: {
response: {
accessToken: 'cmFuZG9tLWJ5dGVz',
userId: 'immich_id',
userId: 'user-id',
userEmail: 'immich@test.com',
firstName: 'immich_first_name',
lastName: 'immich_last_name',

View File

@ -5,6 +5,7 @@ export const newUserRepositoryMock = (): jest.Mocked<IUserRepository> => {
get: jest.fn(),
getAdmin: jest.fn(),
getByEmail: jest.fn(),
getByStorageLabel: jest.fn(),
getByOAuthId: jest.fn(),
getUserStats: jest.fn(),
getList: jest.fn(),

View File

@ -27,6 +27,9 @@ export class UserEntity {
@Column({ unique: true })
email!: string;
@Column({ type: 'varchar', unique: true, default: null })
storageLabel!: string | null;
@Column({ default: '', select: false })
password?: string;

View File

@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddStorageLabel1684410565398 implements MigrationInterface {
name = 'AddStorageLabel1684410565398'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ADD "storageLabel" character varying`);
await queryRunner.query(`ALTER TABLE "users" ADD CONSTRAINT "UQ_b309cf34fa58137c416b32cea3a" UNIQUE ("storageLabel")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "UQ_b309cf34fa58137c416b32cea3a"`);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "storageLabel"`);
}
}

View File

@ -6,10 +6,7 @@ import { UserEntity } from '../entities';
@Injectable()
export class UserRepository implements IUserRepository {
constructor(
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
) {}
constructor(@InjectRepository(UserEntity) private userRepository: Repository<UserEntity>) {}
async get(userId: string, withDeleted?: boolean): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { id: userId }, withDeleted: withDeleted });
@ -29,6 +26,10 @@ export class UserRepository implements IUserRepository {
return builder.getOne();
}
async getByStorageLabel(storageLabel: string): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { storageLabel } });
}
async getByOAuthId(oauthId: string): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { oauthId } });
}
@ -37,13 +38,9 @@ export class UserRepository implements IUserRepository {
return this.userRepository.find({ withDeleted: true, where: { deletedAt: Not(IsNull()) } });
}
async getList({ excludeId }: UserListFilter = {}): Promise<UserEntity[]> {
if (!excludeId) {
return this.userRepository.find(); // TODO: this should also be ordered the same as below
}
async getList({ withDeleted }: UserListFilter = {}): Promise<UserEntity[]> {
return this.userRepository.find({
where: { id: Not(excludeId) },
withDeleted: true,
withDeleted,
order: {
createdAt: 'DESC',
},

View File

@ -910,6 +910,12 @@ export interface CreateUserDto {
* @memberof CreateUserDto
*/
'lastName': string;
/**
*
* @type {string}
* @memberof CreateUserDto
*/
'storageLabel'?: string | null;
}
/**
*
@ -2450,6 +2456,12 @@ export interface UpdateTagDto {
* @interface UpdateUserDto
*/
export interface UpdateUserDto {
/**
*
* @type {string}
* @memberof UpdateUserDto
*/
'id': string;
/**
*
* @type {string}
@ -2479,7 +2491,7 @@ export interface UpdateUserDto {
* @type {string}
* @memberof UpdateUserDto
*/
'id': string;
'storageLabel'?: string;
/**
*
* @type {boolean}
@ -2579,6 +2591,12 @@ export interface UserResponseDto {
* @memberof UserResponseDto
*/
'lastName': string;
/**
*
* @type {string}
* @memberof UserResponseDto
*/
'storageLabel': string | null;
/**
*
* @type {string}

View File

@ -2,15 +2,24 @@
import { api, UserResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
import Button from '../elements/buttons/button.svelte';
import { handleError } from '../../utils/handle-error';
export let user: UserResponseDto;
const dispatch = createEventDispatcher();
const deleteUser = async () => {
const deletedUser = await api.userApi.deleteUser(user.id);
if (deletedUser.data.deletedAt != null) dispatch('user-delete-success');
else dispatch('user-delete-fail');
try {
const deletedUser = await api.userApi.deleteUser(user.id);
if (deletedUser.data.deletedAt != null) {
dispatch('user-delete-success');
} else {
dispatch('user-delete-fail');
}
} catch (error) {
handleError(error, 'Unable to delete user');
dispatch('user-delete-fail');
}
};
</script>

View File

@ -171,14 +171,14 @@
</p>
<p class="text-xs">
{user.id} is the user's ID
<code>{user.storageLabel || user.id}</code> is the user's Storage Label
</p>
<p
class="text-xs p-4 bg-gray-200 dark:bg-gray-700 dark:text-immich-dark-fg py-2 rounded-lg mt-2"
>
<span class="text-immich-fg/25 dark:text-immich-dark-fg/50"
>UPLOAD_LOCATION/{user.id}</span
>UPLOAD_LOCATION/{user.storageLabel || user.id}</span
>/{parsedTemplate()}.jpg
</p>

View File

@ -21,8 +21,8 @@
await getSharedLinks();
const { data } = await api.userApi.getAllUsers(false);
// remove soft deleted users
users = data.filter((user) => !user.deletedAt);
// remove invalid users
users = data.filter((user) => !(user.deletedAt || user.id === album.ownerId));
// Remove the existed shared users from the album
sharedUsersInAlbum.forEach((sharedUser) => {

View File

@ -7,8 +7,10 @@
NotificationType
} from '../shared-components/notification/notification';
import Button from '../elements/buttons/button.svelte';
import { handleError } from '../../utils/handle-error';
export let user: UserResponseDto;
export let canResetPassword = true;
let error: string;
let success: string;
@ -17,18 +19,20 @@
const editUser = async () => {
try {
const { id, email, firstName, lastName } = user;
const { status } = await api.userApi.updateUser({ id, email, firstName, lastName });
const { id, email, firstName, lastName, storageLabel } = user;
const { status } = await api.userApi.updateUser({
id,
email,
firstName,
lastName,
storageLabel: storageLabel || ''
});
if (status === 200) {
dispatch('edit-success');
}
} catch (e) {
console.error('Error updating user ', e);
notificationController.show({
message: 'Error updating user, check console for more details',
type: NotificationType.Error
});
} catch (error) {
handleError(error, 'Unable to update user');
}
};
@ -105,6 +109,24 @@
/>
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="storage-label">Storage Label</label>
<input
class="immich-form-input"
id="storage-label"
name="storage-label"
type="text"
bind:value={user.storageLabel}
/>
<p>
Note: To apply the Storage Label to previously uploaded assets, run the <a
href="/admin/jobs-status"
class="text-immich-primary dark:text-immich-dark-primary">Storage Migration Job</a
>
</p>
</div>
{#if error}
<p class="text-red-400 ml-4 text-sm">{error}</p>
{/if}
@ -113,7 +135,9 @@
<p class="text-immich-primary ml-4 text-sm">{success}</p>
{/if}
<div class="flex w-full px-4 gap-4 mt-8">
<Button color="light-red" fullwidth on:click={resetPassword}>Reset password</Button>
{#if canResetPassword}
<Button color="light-red" fullwidth on:click={resetPassword}>Reset password</Button>
{/if}
<Button type="submit" fullwidth>Confirm</Button>
</div>
</form>

View File

@ -6,6 +6,8 @@
import Button from '../elements/buttons/button.svelte';
import { createEventDispatcher, onMount } from 'svelte';
export let user: UserResponseDto;
let availableUsers: UserResponseDto[] = [];
let selectedUsers: UserResponseDto[] = [];
@ -15,8 +17,8 @@
// TODO: update endpoint to have a query param for deleted users
let { data: users } = await api.userApi.getAllUsers(false);
// remove soft deleted users
users = users.filter((user) => !user.deletedAt);
// remove invalid users
users = users.filter((_user) => !(_user.deletedAt || _user.id === user.id));
// exclude partners from the list of users available for selection
const { data: partners } = await api.partnerApi.getPartners('shared-by');

View File

@ -9,6 +9,8 @@
import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
export let user: UserResponseDto;
let partners: UserResponseDto[] = [];
let createPartner = false;
let removePartner: UserResponseDto | null = null;
@ -83,6 +85,7 @@
{#if createPartner}
<PartnerSelectionModal
{user}
on:close={() => (createPartner = false)}
on:add-users={(event) => handleCreatePartners(event.detail)}
/>

View File

@ -65,6 +65,14 @@
required={true}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="STORAGE LABEL"
disabled={true}
value={user.storageLabel || ''}
required={false}
/>
<div class="flex justify-end">
<Button type="submit" size="sm" on:click={() => handleSaveProfile()}>Save</Button>
</div>

View File

@ -54,5 +54,5 @@
</SettingAccordion>
<SettingAccordion title="Sharing" subtitle="Manage sharing with partners">
<PartnerSettings />
<PartnerSettings {user} />
</SettingAccordion>

View File

@ -13,6 +13,9 @@
import { page } from '$app/stores';
import { locale } from '$lib/stores/preferences.store';
import Button from '$lib/components/elements/buttons/button.svelte';
import type { PageData } from './$types';
export let data: PageData;
let allUsers: UserResponseDto[] = [];
let shouldShowEditUserForm = false;
@ -113,6 +116,7 @@
<FullScreenModal on:clickOutside={() => (shouldShowEditUserForm = false)}>
<EditUserForm
user={selectedUser}
canResetPassword={selectedUser?.id !== data.user.id}
on:edit-success={onEditUserSuccess}
on:reset-password-success={onEditPasswordSuccess}
/>
@ -195,12 +199,14 @@
>
<PencilOutline size="16" />
</button>
<button
on:click={() => deleteUserHandler(user)}
class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
>
<TrashCanOutline size="16" />
</button>
{#if user.id !== data.user.id}
<button
on:click={() => deleteUserHandler(user)}
class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
>
<TrashCanOutline size="16" />
</button>
{/if}
{/if}
{#if isDeleted(user)}
<button