2024-10-02 10:54:35 -04:00
|
|
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
2024-02-08 16:56:19 -05:00
|
|
|
import { DateTime } from 'luxon';
|
2024-05-26 18:15:52 -04:00
|
|
|
import { SALT_ROUNDS } from 'src/constants';
|
2024-09-27 10:28:42 -04:00
|
|
|
import { StorageCore } from 'src/cores/storage.core';
|
2024-10-31 13:42:58 -04:00
|
|
|
import { OnJob } from 'src/decorators';
|
2024-03-20 23:53:07 +01:00
|
|
|
import { AuthDto } from 'src/dtos/auth.dto';
|
2024-07-01 18:43:16 +01:00
|
|
|
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
|
2024-07-05 09:08:36 -04:00
|
|
|
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
|
2024-09-17 03:48:15 +02:00
|
|
|
import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
2024-07-05 09:08:36 -04:00
|
|
|
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
|
2024-08-15 06:57:01 -04:00
|
|
|
import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
|
2024-05-26 18:15:52 -04:00
|
|
|
import { UserEntity } from 'src/entities/user.entity';
|
2024-09-27 10:28:42 -04:00
|
|
|
import { CacheControl, StorageFolder, UserMetadataKey } from 'src/enum';
|
2024-10-31 13:42:58 -04:00
|
|
|
import { JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface';
|
2025-02-11 14:08:13 -05:00
|
|
|
import { UserFindOptions } from 'src/repositories/user.repository';
|
2024-09-30 17:31:21 -04:00
|
|
|
import { BaseService } from 'src/services/base.service';
|
2024-09-27 10:28:42 -04:00
|
|
|
import { ImmichFileResponse } from 'src/utils/file';
|
2024-05-27 22:16:53 -04:00
|
|
|
import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences';
|
2022-02-03 10:06:44 -06:00
|
|
|
|
|
|
|
|
@Injectable()
|
2024-09-30 17:31:21 -04:00
|
|
|
export class UserService extends BaseService {
|
2024-11-26 10:51:01 -05:00
|
|
|
async search(auth: AuthDto): Promise<UserResponseDto[]> {
|
|
|
|
|
const config = await this.getConfig({ withCache: false });
|
|
|
|
|
|
|
|
|
|
let users: UserEntity[] = [auth.user];
|
|
|
|
|
if (auth.user.isAdmin || config.server.publicUsers) {
|
|
|
|
|
users = await this.userRepository.getList({ withDeleted: false });
|
|
|
|
|
}
|
|
|
|
|
|
2024-04-17 08:27:04 -04:00
|
|
|
return users.map((user) => mapUser(user));
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-26 18:15:52 -04:00
|
|
|
getMe(auth: AuthDto): UserAdminResponseDto {
|
|
|
|
|
return mapUserAdmin(auth.user);
|
2022-06-27 15:13:07 -05:00
|
|
|
}
|
|
|
|
|
|
2024-05-26 18:15:52 -04:00
|
|
|
async updateMe({ user }: AuthDto, dto: UserUpdateMeDto): Promise<UserAdminResponseDto> {
|
|
|
|
|
if (dto.email) {
|
|
|
|
|
const duplicate = await this.userRepository.getByEmail(dto.email);
|
|
|
|
|
if (duplicate && duplicate.id !== user.id) {
|
|
|
|
|
throw new BadRequestException('Email already in use by another account');
|
|
|
|
|
}
|
2022-11-07 16:53:47 -05:00
|
|
|
}
|
2023-10-31 11:01:32 -04:00
|
|
|
|
2024-05-26 18:15:52 -04:00
|
|
|
const update: Partial<UserEntity> = {
|
|
|
|
|
email: dto.email,
|
|
|
|
|
name: dto.name,
|
|
|
|
|
};
|
2024-03-08 17:49:39 -05:00
|
|
|
|
2024-05-26 18:15:52 -04:00
|
|
|
if (dto.password) {
|
|
|
|
|
const hashedPassword = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS);
|
|
|
|
|
update.password = hashedPassword;
|
|
|
|
|
update.shouldChangePassword = false;
|
2024-03-08 17:49:39 -05:00
|
|
|
}
|
|
|
|
|
|
2024-05-26 18:15:52 -04:00
|
|
|
const updatedUser = await this.userRepository.update(user.id, update);
|
|
|
|
|
|
|
|
|
|
return mapUserAdmin(updatedUser);
|
2022-11-07 16:53:47 -05:00
|
|
|
}
|
|
|
|
|
|
2024-05-27 22:16:53 -04:00
|
|
|
getMyPreferences({ user }: AuthDto): UserPreferencesResponseDto {
|
|
|
|
|
const preferences = getPreferences(user);
|
|
|
|
|
return mapPreferences(preferences);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async updateMyPreferences({ user }: AuthDto, dto: UserPreferencesUpdateDto) {
|
|
|
|
|
const preferences = mergePreferences(user, dto);
|
|
|
|
|
|
|
|
|
|
await this.userRepository.upsertMetadata(user.id, {
|
|
|
|
|
key: UserMetadataKey.PREFERENCES,
|
|
|
|
|
value: getPreferencesPartial(user, preferences),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return mapPreferences(preferences);
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-26 18:15:52 -04:00
|
|
|
async get(id: string): Promise<UserResponseDto> {
|
|
|
|
|
const user = await this.findOrFail(id, { withDeleted: false });
|
|
|
|
|
return mapUser(user);
|
2022-11-07 16:53:47 -05:00
|
|
|
}
|
|
|
|
|
|
2024-09-17 03:48:15 +02:00
|
|
|
async createProfileImage(auth: AuthDto, file: Express.Multer.File): Promise<CreateProfileImageResponseDto> {
|
2023-12-09 23:34:12 -05:00
|
|
|
const { profileImagePath: oldpath } = await this.findOrFail(auth.user.id, { withDeleted: false });
|
2024-09-17 03:48:15 +02:00
|
|
|
|
|
|
|
|
const user = await this.userRepository.update(auth.user.id, {
|
|
|
|
|
profileImagePath: file.path,
|
|
|
|
|
profileChangedAt: new Date(),
|
|
|
|
|
});
|
|
|
|
|
|
2023-11-14 04:10:35 +01:00
|
|
|
if (oldpath !== '') {
|
|
|
|
|
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [oldpath] } });
|
|
|
|
|
}
|
2024-09-17 03:48:15 +02:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
userId: user.id,
|
|
|
|
|
profileImagePath: user.profileImagePath,
|
|
|
|
|
profileChangedAt: user.profileChangedAt,
|
|
|
|
|
};
|
2022-05-27 22:24:58 -05:00
|
|
|
}
|
2022-05-27 22:15:35 -05:00
|
|
|
|
2023-12-09 23:34:12 -05:00
|
|
|
async deleteProfileImage(auth: AuthDto): Promise<void> {
|
|
|
|
|
const user = await this.findOrFail(auth.user.id, { withDeleted: false });
|
2023-11-14 04:10:35 +01:00
|
|
|
if (user.profileImagePath === '') {
|
|
|
|
|
throw new BadRequestException("Can't delete a missing profile Image");
|
|
|
|
|
}
|
2024-09-17 03:48:15 +02:00
|
|
|
await this.userRepository.update(auth.user.id, { profileImagePath: '', profileChangedAt: new Date() });
|
2023-11-14 04:10:35 +01:00
|
|
|
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [user.profileImagePath] } });
|
|
|
|
|
}
|
|
|
|
|
|
2023-12-12 09:58:25 -05:00
|
|
|
async getProfileImage(id: string): Promise<ImmichFileResponse> {
|
2023-10-31 11:01:32 -04:00
|
|
|
const user = await this.findOrFail(id, {});
|
2023-10-30 17:02:36 -04:00
|
|
|
if (!user.profileImagePath) {
|
|
|
|
|
throw new NotFoundException('User does not have a profile image');
|
|
|
|
|
}
|
2023-12-12 09:58:25 -05:00
|
|
|
|
|
|
|
|
return new ImmichFileResponse({
|
|
|
|
|
path: user.profileImagePath,
|
|
|
|
|
contentType: 'image/jpeg',
|
2023-12-18 11:33:46 -05:00
|
|
|
cacheControl: CacheControl.NONE,
|
2023-12-12 09:58:25 -05:00
|
|
|
});
|
2022-05-27 22:15:35 -05:00
|
|
|
}
|
2023-01-16 13:09:04 -05:00
|
|
|
|
2024-07-01 18:43:16 +01:00
|
|
|
getLicense({ user }: AuthDto): LicenseResponseDto {
|
|
|
|
|
const license = user.metadata.find(
|
|
|
|
|
(item): item is UserMetadataEntity<UserMetadataKey.LICENSE> => item.key === UserMetadataKey.LICENSE,
|
|
|
|
|
);
|
|
|
|
|
if (!license) {
|
|
|
|
|
throw new NotFoundException();
|
|
|
|
|
}
|
|
|
|
|
return license.value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async deleteLicense({ user }: AuthDto): Promise<void> {
|
|
|
|
|
await this.userRepository.deleteMetadata(user.id, UserMetadataKey.LICENSE);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async setLicense(auth: AuthDto, license: LicenseKeyDto): Promise<LicenseResponseDto> {
|
2024-07-26 00:27:44 -04:00
|
|
|
if (!license.licenseKey.startsWith('IMCL-') && !license.licenseKey.startsWith('IMSV-')) {
|
2024-07-01 18:43:16 +01:00
|
|
|
throw new BadRequestException('Invalid license key');
|
|
|
|
|
}
|
2024-07-26 00:27:44 -04:00
|
|
|
|
2024-10-03 15:45:37 -04:00
|
|
|
const { licensePublicKey } = this.configRepository.getEnv();
|
|
|
|
|
|
2024-07-26 00:27:44 -04:00
|
|
|
const clientLicenseValid = this.cryptoRepository.verifySha256(
|
2024-07-01 18:43:16 +01:00
|
|
|
license.licenseKey,
|
|
|
|
|
license.activationKey,
|
2024-10-03 15:45:37 -04:00
|
|
|
licensePublicKey.client,
|
2024-07-01 18:43:16 +01:00
|
|
|
);
|
|
|
|
|
|
2024-07-26 00:27:44 -04:00
|
|
|
const serverLicenseValid = this.cryptoRepository.verifySha256(
|
|
|
|
|
license.licenseKey,
|
|
|
|
|
license.activationKey,
|
2024-10-03 15:45:37 -04:00
|
|
|
licensePublicKey.server,
|
2024-07-26 00:27:44 -04:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!clientLicenseValid && !serverLicenseValid) {
|
2024-07-01 18:43:16 +01:00
|
|
|
throw new BadRequestException('Invalid license key');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const licenseData = {
|
|
|
|
|
...license,
|
|
|
|
|
activatedAt: new Date(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await this.userRepository.upsertMetadata(auth.user.id, {
|
|
|
|
|
key: UserMetadataKey.LICENSE,
|
|
|
|
|
value: licenseData,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return licenseData;
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-31 13:42:58 -04:00
|
|
|
@OnJob({ name: JobName.USER_SYNC_USAGE, queue: QueueName.BACKGROUND_TASK })
|
2024-03-15 14:16:54 +01:00
|
|
|
async handleUserSyncUsage(): Promise<JobStatus> {
|
2024-01-12 18:43:36 -06:00
|
|
|
await this.userRepository.syncUsage();
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2024-01-12 18:43:36 -06:00
|
|
|
}
|
|
|
|
|
|
2024-10-31 13:42:58 -04:00
|
|
|
@OnJob({ name: JobName.USER_DELETE_CHECK, queue: QueueName.BACKGROUND_TASK })
|
2024-03-15 14:16:54 +01:00
|
|
|
async handleUserDeleteCheck(): Promise<JobStatus> {
|
2023-02-25 09:12:03 -05:00
|
|
|
const users = await this.userRepository.getDeletedUsers();
|
2024-09-30 17:31:21 -04:00
|
|
|
const config = await this.getConfig({ withCache: false });
|
2024-01-01 15:45:42 -05:00
|
|
|
await this.jobRepository.queueAll(
|
|
|
|
|
users.flatMap((user) =>
|
2024-03-06 00:45:40 -05:00
|
|
|
this.isReadyForDeletion(user, config.user.deleteDelay)
|
|
|
|
|
? [{ name: JobName.USER_DELETION, data: { id: user.id } }]
|
|
|
|
|
: [],
|
2024-01-01 15:45:42 -05:00
|
|
|
),
|
|
|
|
|
);
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-02-25 09:12:03 -05:00
|
|
|
}
|
|
|
|
|
|
2024-10-31 13:42:58 -04:00
|
|
|
@OnJob({ name: JobName.USER_DELETION, queue: QueueName.BACKGROUND_TASK })
|
|
|
|
|
async handleUserDelete({ id, force }: JobOf<JobName.USER_DELETION>): Promise<JobStatus> {
|
2024-09-30 17:31:21 -04:00
|
|
|
const config = await this.getConfig({ withCache: false });
|
2023-10-31 11:01:32 -04:00
|
|
|
const user = await this.userRepository.get(id, { withDeleted: true });
|
2023-05-26 15:43:24 -04:00
|
|
|
if (!user) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2023-05-26 15:43:24 -04:00
|
|
|
}
|
2023-02-25 09:12:03 -05:00
|
|
|
|
|
|
|
|
// just for extra protection here
|
2024-03-08 17:49:39 -05:00
|
|
|
if (!force && !this.isReadyForDeletion(user, config.user.deleteDelay)) {
|
2023-05-26 15:43:24 -04:00
|
|
|
this.logger.warn(`Skipped user that was not ready for deletion: id=${id}`);
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SKIPPED;
|
2023-02-25 09:12:03 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.logger.log(`Deleting user: ${user.id}`);
|
|
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
const folders = [
|
2023-10-23 17:52:21 +02:00
|
|
|
StorageCore.getLibraryFolder(user),
|
|
|
|
|
StorageCore.getFolderLocation(StorageFolder.UPLOAD, user.id),
|
|
|
|
|
StorageCore.getFolderLocation(StorageFolder.PROFILE, user.id),
|
|
|
|
|
StorageCore.getFolderLocation(StorageFolder.THUMBNAILS, user.id),
|
|
|
|
|
StorageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, user.id),
|
2023-05-26 15:43:24 -04:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (const folder of folders) {
|
|
|
|
|
this.logger.warn(`Removing user from filesystem: ${folder}`);
|
|
|
|
|
await this.storageRepository.unlinkDir(folder, { recursive: true, force: true });
|
|
|
|
|
}
|
2023-02-25 09:12:03 -05:00
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
this.logger.warn(`Removing user from database: ${user.id}`);
|
|
|
|
|
await this.albumRepository.deleteAll(user.id);
|
|
|
|
|
await this.userRepository.delete(user, true);
|
|
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-02-25 09:12:03 -05:00
|
|
|
}
|
|
|
|
|
|
2024-03-06 00:45:40 -05:00
|
|
|
private isReadyForDeletion(user: UserEntity, deleteDelay: number): boolean {
|
2023-02-25 09:12:03 -05:00
|
|
|
if (!user.deletedAt) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2024-03-06 00:45:40 -05:00
|
|
|
return DateTime.now().minus({ days: deleteDelay }) > DateTime.fromJSDate(user.deletedAt);
|
2023-02-25 09:12:03 -05:00
|
|
|
}
|
2023-10-30 19:38:34 -04:00
|
|
|
|
2023-10-31 11:01:32 -04:00
|
|
|
private async findOrFail(id: string, options: UserFindOptions) {
|
|
|
|
|
const user = await this.userRepository.get(id, options);
|
2023-10-30 19:38:34 -04:00
|
|
|
if (!user) {
|
|
|
|
|
throw new BadRequestException('User not found');
|
|
|
|
|
}
|
|
|
|
|
return user;
|
|
|
|
|
}
|
2022-02-03 10:06:44 -06:00
|
|
|
}
|