mirror of
https://github.com/immich-app/immich.git
synced 2026-06-05 06:15:24 -04:00
b3d49045de
* Feat - Heatmap * Implemented Comments to prettify and code cleanup * fixing code to pass cases. * fixing errors for OpenAPI Clients * Improving the code. * Fix code * Rerun generated client check * Rerun generated client * feat: command for user pages (#28554) * fix(web): timeline stuttering with many assets in 1 day (#28509) * fix(web): timeline stuttering with many assets in 1 day * cache isInOrNearViewport per day * skip inOrNearViewport check on first run * chore(ml): allow insightface 1.x (#28595) * chore(ml): allow insightface 1.x The new insightface 1.0 release appears to have no breaking code changes nor relevant license changes ([before](https://github.com/deepinsight/insightface/blob/2a78baec428354883e0cda39c54b555a5ed8358a/README.md), [after](https://github.com/deepinsight/insightface/blob/70f3269ea628d0658c5723976944c9de414e96f8/README.md), c.f. https://github.com/immich-app/immich/blob/fd7ddfef54cdf2b6256c4fc08bc5ff3f86176775/machine-learning/README.md), and it works on my machine. * Update uv.lock * please excuse my incompetence * Triggering the actions. * bad merge * Fix code * Code clear * Resolve conflict * Resolve conflict * Resolve conflict * Resolve errors * Resolve errors * Resolve errors more * chore: clean up --------- Co-authored-by: Alex <alex.tran1502@gmail.com> Co-authored-by: Ben Beckford <ben@benjaminbeckford.com> Co-authored-by: Aaron Liu <aaronliu0130@gmail.com> Co-authored-by: Jason Rasmussen <jason@rasm.me>
311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
import { Updateable } from 'kysely';
|
|
import { DateTime } from 'luxon';
|
|
import { SALT_ROUNDS } from 'src/constants';
|
|
import { StorageCore } from 'src/cores/storage.core';
|
|
import { OnEvent, OnJob } from 'src/decorators';
|
|
import { AuthDto } from 'src/dtos/auth.dto';
|
|
import { CalendarHeatmapDto, CalendarHeatmapResponseDto } from 'src/dtos/calendar-heatmap.dto';
|
|
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
|
|
import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
|
|
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
|
|
import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
|
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
|
|
import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum';
|
|
import { ArgOf } from 'src/repositories/event.repository';
|
|
import { UserFindOptions } from 'src/repositories/user.repository';
|
|
import { UserTable } from 'src/schema/tables/user.table';
|
|
import { BaseService } from 'src/services/base.service';
|
|
import { getCalendarHeatmap } from 'src/services/shared/user-methods';
|
|
import { JobOf, UserMetadataItem } from 'src/types';
|
|
import { ImmichFileResponse } from 'src/utils/file';
|
|
import { mimeTypes } from 'src/utils/mime-types';
|
|
import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences';
|
|
import { generateProfileImage } from 'src/utils/profile-image';
|
|
|
|
@Injectable()
|
|
export class UserService extends BaseService {
|
|
async search(auth: AuthDto): Promise<UserResponseDto[]> {
|
|
const config = await this.getConfig({ withCache: false });
|
|
|
|
let users;
|
|
if (auth.user.isAdmin || config.server.publicUsers) {
|
|
users = await this.userRepository.getList({ withDeleted: false });
|
|
} else {
|
|
const authUser = await this.userRepository.get(auth.user.id, {});
|
|
users = authUser ? [authUser] : [];
|
|
}
|
|
|
|
return users.map((user) => mapUser(user));
|
|
}
|
|
|
|
async getMe(auth: AuthDto): Promise<UserAdminResponseDto> {
|
|
const user = await this.userRepository.get(auth.user.id, {});
|
|
if (!user) {
|
|
throw new BadRequestException('User not found');
|
|
}
|
|
|
|
return mapUserAdmin(user);
|
|
}
|
|
|
|
getCalendarHeatmap(auth: AuthDto, dto: CalendarHeatmapDto): Promise<CalendarHeatmapResponseDto> {
|
|
return getCalendarHeatmap(auth.user.id, dto, { asset: this.assetRepository });
|
|
}
|
|
|
|
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) {
|
|
this.logger.warn('Email already in use by another account');
|
|
throw new BadRequestException('Email is not available');
|
|
}
|
|
}
|
|
|
|
const update: Updateable<UserTable> = {
|
|
email: dto.email,
|
|
name: dto.name,
|
|
avatarColor: dto.avatarColor,
|
|
};
|
|
|
|
if (dto.password) {
|
|
const hashedPassword = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS);
|
|
update.password = hashedPassword;
|
|
update.shouldChangePassword = false;
|
|
}
|
|
|
|
const updatedUser = await this.userRepository.update(user.id, update);
|
|
|
|
return mapUserAdmin(updatedUser);
|
|
}
|
|
|
|
async getMyPreferences(auth: AuthDto): Promise<UserPreferencesResponseDto> {
|
|
const metadata = await this.userRepository.getMetadata(auth.user.id);
|
|
return mapPreferences(getPreferences(metadata));
|
|
}
|
|
|
|
async updateMyPreferences(auth: AuthDto, dto: UserPreferencesUpdateDto) {
|
|
const metadata = await this.userRepository.getMetadata(auth.user.id);
|
|
const updated = mergePreferences(getPreferences(metadata), dto);
|
|
|
|
await this.userRepository.upsertMetadata(auth.user.id, {
|
|
key: UserMetadataKey.Preferences,
|
|
value: getPreferencesPartial(updated),
|
|
});
|
|
|
|
return mapPreferences(updated);
|
|
}
|
|
|
|
async get(id: string): Promise<UserResponseDto> {
|
|
const user = await this.findOrFail(id, { withDeleted: false });
|
|
return mapUser(user);
|
|
}
|
|
|
|
async createProfileImage(auth: AuthDto, file: Express.Multer.File): Promise<CreateProfileImageResponseDto> {
|
|
const { profileImagePath: oldPath } = await this.findOrFail(auth.user.id, { withDeleted: false });
|
|
|
|
let profileImagePath: string;
|
|
try {
|
|
const config = await this.getConfig({ withCache: true });
|
|
profileImagePath = await generateProfileImage(
|
|
{ media: this.mediaRepository, crypto: this.cryptoRepository, storageCore: this.storageCore },
|
|
config,
|
|
auth.user.id,
|
|
file.path,
|
|
);
|
|
} catch (error) {
|
|
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [file.path] } });
|
|
throw new BadRequestException('Unable to process profile image', { cause: error });
|
|
}
|
|
|
|
const user = await this.userRepository.update(auth.user.id, {
|
|
profileImagePath,
|
|
profileChangedAt: new Date(),
|
|
});
|
|
|
|
const toDelete = [file.path, ...(oldPath ? [oldPath] : [])];
|
|
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: toDelete } });
|
|
|
|
return {
|
|
userId: user.id,
|
|
profileImagePath: user.profileImagePath,
|
|
profileChangedAt: user.profileChangedAt,
|
|
};
|
|
}
|
|
|
|
async deleteProfileImage(auth: AuthDto): Promise<void> {
|
|
const user = await this.findOrFail(auth.user.id, { withDeleted: false });
|
|
if (user.profileImagePath === '') {
|
|
throw new BadRequestException("Can't delete a missing profile Image");
|
|
}
|
|
await this.userRepository.update(auth.user.id, { profileImagePath: '', profileChangedAt: new Date() });
|
|
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [user.profileImagePath] } });
|
|
}
|
|
|
|
async getProfileImage(id: string): Promise<ImmichFileResponse> {
|
|
const user = await this.userRepository.get(id, {});
|
|
if (!user || !user.profileImagePath) {
|
|
this.logger.debug('User or profile image not found');
|
|
throw new NotFoundException();
|
|
}
|
|
|
|
return new ImmichFileResponse({
|
|
path: user.profileImagePath,
|
|
contentType: mimeTypes.lookup(user.profileImagePath),
|
|
cacheControl: CacheControl.None,
|
|
});
|
|
}
|
|
|
|
async getLicense(auth: AuthDto): Promise<LicenseResponseDto> {
|
|
const metadata = await this.userRepository.getMetadata(auth.user.id);
|
|
|
|
const license = metadata.find(
|
|
(item): item is UserMetadataItem<UserMetadataKey.License> => item.key === UserMetadataKey.License,
|
|
);
|
|
if (!license) {
|
|
throw new NotFoundException();
|
|
}
|
|
return { ...license.value, activatedAt: new Date(license.value.activatedAt) };
|
|
}
|
|
|
|
async deleteLicense({ user }: AuthDto): Promise<void> {
|
|
await this.userRepository.deleteMetadata(user.id, UserMetadataKey.License);
|
|
}
|
|
|
|
async setLicense(auth: AuthDto, license: LicenseKeyDto): Promise<LicenseResponseDto> {
|
|
if (!license.licenseKey.startsWith('IMCL-') && !license.licenseKey.startsWith('IMSV-')) {
|
|
throw new BadRequestException('Invalid license key');
|
|
}
|
|
|
|
const { licensePublicKey } = this.configRepository.getEnv();
|
|
|
|
const clientLicenseValid = this.cryptoRepository.verifySha256(
|
|
license.licenseKey,
|
|
license.activationKey,
|
|
licensePublicKey.client,
|
|
);
|
|
|
|
const serverLicenseValid = this.cryptoRepository.verifySha256(
|
|
license.licenseKey,
|
|
license.activationKey,
|
|
licensePublicKey.server,
|
|
);
|
|
|
|
if (!clientLicenseValid && !serverLicenseValid) {
|
|
throw new BadRequestException('Invalid license key');
|
|
}
|
|
|
|
const activatedAt = new Date();
|
|
|
|
await this.userRepository.upsertMetadata(auth.user.id, {
|
|
key: UserMetadataKey.License,
|
|
value: { ...license, activatedAt: activatedAt.toISOString() },
|
|
});
|
|
|
|
return { ...license, activatedAt };
|
|
}
|
|
|
|
async getOnboarding(auth: AuthDto): Promise<OnboardingResponseDto> {
|
|
const metadata = await this.userRepository.getMetadata(auth.user.id);
|
|
|
|
const onboardingData = metadata.find(
|
|
(item): item is UserMetadataItem<UserMetadataKey.Onboarding> => item.key === UserMetadataKey.Onboarding,
|
|
)?.value;
|
|
|
|
if (!onboardingData) {
|
|
return { isOnboarded: false };
|
|
}
|
|
|
|
return {
|
|
isOnboarded: onboardingData.isOnboarded,
|
|
};
|
|
}
|
|
|
|
async deleteOnboarding({ user }: AuthDto): Promise<void> {
|
|
await this.userRepository.deleteMetadata(user.id, UserMetadataKey.Onboarding);
|
|
}
|
|
|
|
async setOnboarding(auth: AuthDto, onboarding: OnboardingDto): Promise<OnboardingResponseDto> {
|
|
await this.userRepository.upsertMetadata(auth.user.id, {
|
|
key: UserMetadataKey.Onboarding,
|
|
value: {
|
|
isOnboarded: onboarding.isOnboarded,
|
|
},
|
|
});
|
|
|
|
return {
|
|
isOnboarded: onboarding.isOnboarded,
|
|
};
|
|
}
|
|
|
|
@OnEvent({ name: 'AssetCreate' })
|
|
async onAssetCreate({ asset, file }: ArgOf<'AssetCreate'>) {
|
|
await this.userRepository.updateUsage(asset.ownerId, file.size);
|
|
}
|
|
|
|
@OnJob({ name: JobName.UserSyncUsage, queue: QueueName.BackgroundTask })
|
|
async handleUserSyncUsage(): Promise<JobStatus> {
|
|
await this.userRepository.syncUsage();
|
|
return JobStatus.Success;
|
|
}
|
|
|
|
@OnJob({ name: JobName.UserDeleteCheck, queue: QueueName.BackgroundTask })
|
|
async handleUserDeleteCheck(): Promise<JobStatus> {
|
|
const config = await this.getConfig({ withCache: false });
|
|
const users = await this.userRepository.getDeletedAfter(DateTime.now().minus({ days: config.user.deleteDelay }));
|
|
await this.jobRepository.queueAll(users.map((user) => ({ name: JobName.UserDelete, data: { id: user.id } })));
|
|
return JobStatus.Success;
|
|
}
|
|
|
|
@OnJob({ name: JobName.UserDelete, queue: QueueName.BackgroundTask })
|
|
async handleUserDelete({ id, force }: JobOf<JobName.UserDelete>) {
|
|
const config = await this.getConfig({ withCache: false });
|
|
const user = await this.userRepository.get(id, { withDeleted: true });
|
|
if (!user) {
|
|
return;
|
|
}
|
|
|
|
// just for extra protection here
|
|
if (!force && !this.isReadyForDeletion(user, config.user.deleteDelay)) {
|
|
this.logger.warn(`Skipped user that was not ready for deletion: id=${id}`);
|
|
return;
|
|
}
|
|
|
|
this.logger.log(`Deleting user: ${user.id}`);
|
|
|
|
const folders = [
|
|
StorageCore.getLibraryFolder(user),
|
|
StorageCore.getFolderLocation(StorageFolder.Upload, user.id),
|
|
StorageCore.getFolderLocation(StorageFolder.Profile, user.id),
|
|
StorageCore.getFolderLocation(StorageFolder.Thumbnails, user.id),
|
|
StorageCore.getFolderLocation(StorageFolder.EncodedVideo, user.id),
|
|
];
|
|
|
|
for (const folder of folders) {
|
|
this.logger.warn(`Removing user from filesystem: ${folder}`);
|
|
await this.storageRepository.unlinkDir(folder, { recursive: true, force: true });
|
|
}
|
|
|
|
this.logger.warn(`Removing user from database: ${user.id}`);
|
|
await this.albumRepository.deleteAll(user.id);
|
|
await this.userRepository.delete(user, true);
|
|
|
|
await this.eventRepository.emit('UserDelete', user);
|
|
}
|
|
|
|
private isReadyForDeletion(user: { id: string; deletedAt?: Date | null }, deleteDelay: number): boolean {
|
|
if (!user.deletedAt) {
|
|
return false;
|
|
}
|
|
|
|
return DateTime.now().minus({ days: deleteDelay }) > DateTime.fromJSDate(user.deletedAt);
|
|
}
|
|
|
|
private async findOrFail(id: string, options: UserFindOptions) {
|
|
const user = await this.userRepository.get(id, options);
|
|
if (!user) {
|
|
throw new BadRequestException('User not found');
|
|
}
|
|
return user;
|
|
}
|
|
}
|