1
0
mirror of https://github.com/immich-app/immich.git synced 2025-01-11 06:10:28 +02:00

feat(server,web): make user deletion delay configurable (#7663)

* feat(server,web): make user deletion delay configurable

* alphabetical order

* add min for user.deleteDelay in SettingInputField

* make config.user.deleteDelay SettingInputField min consistent format

* fix e2e test

* update description on user delete delay
This commit is contained in:
Sam Holton 2024-03-06 00:45:40 -05:00 committed by GitHub
parent 52dfe5fc92
commit 9125999d1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 188 additions and 10 deletions

View File

@ -128,6 +128,9 @@ The default configuration looks like this:
"theme": {
"customCss": ""
},
"user": {
"deleteDelay": 7
},
"library": {
"scan": {
"enabled": true,

View File

@ -88,6 +88,7 @@ describe('/server-info', () => {
loginPageMessage: '',
oauthButtonText: 'Login with OAuth',
trashDays: 30,
userDeleteDelay: 7,
isInitialized: true,
externalDomain: '',
isOnboarded: false,

View File

@ -160,6 +160,7 @@ doc/SystemConfigTemplateStorageOptionDto.md
doc/SystemConfigThemeDto.md
doc/SystemConfigThumbnailDto.md
doc/SystemConfigTrashDto.md
doc/SystemConfigUserDto.md
doc/TagApi.md
doc/TagResponseDto.md
doc/TagTypeEnum.md
@ -357,6 +358,7 @@ lib/model/system_config_template_storage_option_dto.dart
lib/model/system_config_theme_dto.dart
lib/model/system_config_thumbnail_dto.dart
lib/model/system_config_trash_dto.dart
lib/model/system_config_user_dto.dart
lib/model/tag_response_dto.dart
lib/model/tag_type_enum.dart
lib/model/thumbnail_format.dart
@ -539,6 +541,7 @@ test/system_config_template_storage_option_dto_test.dart
test/system_config_theme_dto_test.dart
test/system_config_thumbnail_dto_test.dart
test/system_config_trash_dto_test.dart
test/system_config_user_dto_test.dart
test/tag_api_test.dart
test/tag_response_dto_test.dart
test/tag_type_enum_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/SystemConfigUserDto.md generated Normal file

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

@ -9090,6 +9090,9 @@
},
"trashDays": {
"type": "integer"
},
"userDeleteDelay": {
"type": "integer"
}
},
"required": [
@ -9098,7 +9101,8 @@
"isOnboarded",
"loginPageMessage",
"oauthButtonText",
"trashDays"
"trashDays",
"userDeleteDelay"
],
"type": "object"
},
@ -9661,6 +9665,9 @@
},
"trash": {
"$ref": "#/components/schemas/SystemConfigTrashDto"
},
"user": {
"$ref": "#/components/schemas/SystemConfigUserDto"
}
},
"required": [
@ -9678,7 +9685,8 @@
"storageTemplate",
"theme",
"thumbnail",
"trash"
"trash",
"user"
],
"type": "object"
},
@ -10162,6 +10170,17 @@
],
"type": "object"
},
"SystemConfigUserDto": {
"properties": {
"deleteDelay": {
"type": "integer"
}
},
"required": [
"deleteDelay"
],
"type": "object"
},
"TagResponseDto": {
"properties": {
"id": {

View File

@ -705,6 +705,7 @@ export type ServerConfigDto = {
loginPageMessage: string;
oauthButtonText: string;
trashDays: number;
userDeleteDelay: number;
};
export type ServerFeaturesDto = {
configFile: boolean;
@ -918,6 +919,9 @@ export type SystemConfigTrashDto = {
days: number;
enabled: boolean;
};
export type SystemConfigUserDto = {
deleteDelay: number;
};
export type SystemConfigDto = {
ffmpeg: SystemConfigFFmpegDto;
job: SystemConfigJobDto;
@ -934,6 +938,7 @@ export type SystemConfigDto = {
theme: SystemConfigThemeDto;
thumbnail: SystemConfigThumbnailDto;
trash: SystemConfigTrashDto;
user: SystemConfigUserDto;
};
export type SystemConfigTemplateStorageOptionDto = {
dayOptions: string[];

View File

@ -88,6 +88,8 @@ export class ServerConfigDto {
loginPageMessage!: string;
@ApiProperty({ type: 'integer' })
trashDays!: number;
@ApiProperty({ type: 'integer' })
userDeleteDelay!: number;
isInitialized!: boolean;
isOnboarded!: boolean;
externalDomain!: string;

View File

@ -196,6 +196,7 @@ describe(ServerInfoService.name, () => {
loginPageMessage: '',
oauthButtonText: 'Login with OAuth',
trashDays: 30,
userDeleteDelay: 7,
isInitialized: undefined,
isOnboarded: false,
externalDomain: '',

View File

@ -96,6 +96,7 @@ export class ServerInfoService {
return {
loginPageMessage: config.server.loginPageMessage,
trashDays: config.trash.days,
userDeleteDelay: config.user.deleteDelay,
oauthButtonText: config.oauth.buttonText,
isInitialized,
isOnboarded: onboarding?.isOnboarded || false,

View File

@ -0,0 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, Min } from 'class-validator';
export class SystemConfigUserDto {
@IsInt()
@Min(1)
@Type(() => Number)
@ApiProperty({ type: 'integer' })
deleteDelay!: number;
}

View File

@ -16,6 +16,7 @@ import { SystemConfigStorageTemplateDto } from './system-config-storage-template
import { SystemConfigThemeDto } from './system-config-theme.dto';
import { SystemConfigThumbnailDto } from './system-config-thumbnail.dto';
import { SystemConfigTrashDto } from './system-config-trash.dto';
import { SystemConfigUserDto } from './system-config-user.dto';
export class SystemConfigDto implements SystemConfig {
@Type(() => SystemConfigFFmpegDto)
@ -92,6 +93,11 @@ export class SystemConfigDto implements SystemConfig {
@ValidateNested()
@IsObject()
server!: SystemConfigServerDto;
@Type(() => SystemConfigUserDto)
@ValidateNested()
@IsObject()
user!: SystemConfigUserDto;
}
export function mapConfig(config: SystemConfig): SystemConfigDto {

View File

@ -140,6 +140,9 @@ export const defaults = Object.freeze<SystemConfig>({
externalDomain: '',
loginPageMessage: '',
},
user: {
deleteDelay: 7,
},
});
export enum FeatureFlag {

View File

@ -23,6 +23,7 @@ const updates: SystemConfigEntity[] = [
{ key: SystemConfigKey.FFMPEG_CRF, value: 30 },
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true },
{ key: SystemConfigKey.TRASH_DAYS, value: 10 },
{ key: SystemConfigKey.USER_DELETE_DELAY, value: 15 },
];
const updatedConfig = Object.freeze<SystemConfig>({
@ -140,6 +141,9 @@ const updatedConfig = Object.freeze<SystemConfig>({
enabled: false,
},
},
user: {
deleteDelay: 15,
},
});
describe(SystemConfigService.name, () => {
@ -199,6 +203,7 @@ describe(SystemConfigService.name, () => {
{ key: SystemConfigKey.FFMPEG_CRF, value: 30 },
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true },
{ key: SystemConfigKey.TRASH_DAYS, value: 10 },
{ key: SystemConfigKey.USER_DELETE_DELAY, value: 15 },
]);
await expect(sut.getConfig()).resolves.toEqual(updatedConfig);
@ -206,7 +211,12 @@ describe(SystemConfigService.name, () => {
it('should load the config from a file', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
const partialConfig = { ffmpeg: { crf: 30 }, oauth: { autoLaunch: true }, trash: { days: 10 } };
const partialConfig = {
ffmpeg: { crf: 30 },
oauth: { autoLaunch: true },
trash: { days: 10 },
user: { deleteDelay: 15 },
};
configMock.readFile.mockResolvedValue(JSON.stringify(partialConfig));
await expect(sut.getConfig()).resolves.toEqual(updatedConfig);

View File

@ -13,7 +13,9 @@ import {
newJobRepositoryMock,
newLibraryRepositoryMock,
newStorageRepositoryMock,
newSystemConfigRepositoryMock,
newUserRepositoryMock,
systemConfigStub,
userStub,
} from '@test';
import { when } from 'jest-when';
@ -26,6 +28,7 @@ import {
IJobRepository,
ILibraryRepository,
IStorageRepository,
ISystemConfigRepository,
IUserRepository,
} from '../repositories';
import { UpdateUserDto } from './dto/update-user.dto';
@ -48,17 +51,28 @@ describe(UserService.name, () => {
let jobMock: jest.Mocked<IJobRepository>;
let libraryMock: jest.Mocked<ILibraryRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>;
beforeEach(() => {
albumMock = newAlbumRepositoryMock();
assetMock = newAssetRepositoryMock();
configMock = newSystemConfigRepositoryMock();
cryptoRepositoryMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
libraryMock = newLibraryRepositoryMock();
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
sut = new UserService(albumMock, assetMock, cryptoRepositoryMock, jobMock, libraryMock, storageMock, userMock);
sut = new UserService(
albumMock,
assetMock,
cryptoRepositoryMock,
jobMock,
libraryMock,
storageMock,
configMock,
userMock,
);
when(userMock.get).calledWith(authStub.admin.user.id, {}).mockResolvedValue(userStub.admin);
when(userMock.get).calledWith(authStub.admin.user.id, { withDeleted: true }).mockResolvedValue(userStub.admin);
@ -461,6 +475,22 @@ describe(UserService.name, () => {
expect(jobMock.queueAll).toHaveBeenCalledWith([]);
});
it('should skip users not ready for deletion - deleteDelay30', async () => {
configMock.load.mockResolvedValue(systemConfigStub.deleteDelay30);
userMock.getDeletedUsers.mockResolvedValue([
{},
{ deletedAt: undefined },
{ deletedAt: null },
{ deletedAt: makeDeletedAt(15) },
] as UserEntity[]);
await sut.handleUserDeleteCheck();
expect(userMock.getDeletedUsers).toHaveBeenCalled();
expect(jobMock.queue).not.toHaveBeenCalled();
expect(jobMock.queueAll).toHaveBeenCalledWith([]);
});
it('should queue user ready for deletion', async () => {
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) };
userMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]);
@ -470,6 +500,16 @@ describe(UserService.name, () => {
expect(userMock.getDeletedUsers).toHaveBeenCalled();
expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]);
});
it('should queue user ready for deletion - deleteDelay30', async () => {
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(31) };
userMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]);
await sut.handleUserDeleteCheck();
expect(userMock.getDeletedUsers).toHaveBeenCalled();
expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]);
});
});
describe('handleUserDelete', () => {

View File

@ -13,16 +13,19 @@ import {
IJobRepository,
ILibraryRepository,
IStorageRepository,
ISystemConfigRepository,
IUserRepository,
UserFindOptions,
} from '../repositories';
import { StorageCore, StorageFolder } from '../storage';
import { SystemConfigCore } from '../system-config/system-config.core';
import { CreateUserDto, UpdateUserDto } from './dto';
import { CreateProfileImageResponseDto, UserResponseDto, mapCreateProfileImageResponse, mapUser } from './response-dto';
import { UserCore } from './user.core';
@Injectable()
export class UserService {
private configCore: SystemConfigCore;
private logger = new ImmichLogger(UserService.name);
private userCore: UserCore;
@ -33,9 +36,11 @@ export class UserService {
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ILibraryRepository) libraryRepository: ILibraryRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
) {
this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository);
this.configCore = SystemConfigCore.create(configRepository);
}
async getAll(auth: AuthDto, isAll: boolean): Promise<UserResponseDto[]> {
@ -140,22 +145,26 @@ export class UserService {
async handleUserDeleteCheck() {
const users = await this.userRepository.getDeletedUsers();
const config = await this.configCore.getConfig();
await this.jobRepository.queueAll(
users.flatMap((user) =>
this.isReadyForDeletion(user) ? [{ name: JobName.USER_DELETION, data: { id: user.id } }] : [],
this.isReadyForDeletion(user, config.user.deleteDelay)
? [{ name: JobName.USER_DELETION, data: { id: user.id } }]
: [],
),
);
return true;
}
async handleUserDelete({ id }: IEntityJob) {
const config = await this.configCore.getConfig();
const user = await this.userRepository.get(id, { withDeleted: true });
if (!user) {
return false;
}
// just for extra protection here
if (!this.isReadyForDeletion(user)) {
if (!this.isReadyForDeletion(user, config.user.deleteDelay)) {
this.logger.warn(`Skipped user that was not ready for deletion: id=${id}`);
return false;
}
@ -184,12 +193,12 @@ export class UserService {
return true;
}
private isReadyForDeletion(user: UserEntity): boolean {
private isReadyForDeletion(user: UserEntity, deleteDelay: number): boolean {
if (!user.deletedAt) {
return false;
}
return DateTime.now().minus({ days: 7 }) > DateTime.fromJSDate(user.deletedAt);
return DateTime.now().minus({ days: deleteDelay }) > DateTime.fromJSDate(user.deletedAt);
}
private async findOrFail(id: string, options: UserFindOptions) {

View File

@ -108,6 +108,8 @@ export enum SystemConfigKey {
TRASH_DAYS = 'trash.days',
THEME_CUSTOM_CSS = 'theme.customCss',
USER_DELETE_DELAY = 'user.deleteDelay',
}
export enum TranscodePolicy {
@ -276,4 +278,7 @@ export interface SystemConfig {
externalDomain: string;
loginPageMessage: string;
};
user: {
deleteDelay: number;
};
}

View File

@ -27,6 +27,7 @@ export const systemConfigStub: Record<string, SystemConfigEntity[]> = {
{ key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true },
{ key: SystemConfigKey.OAUTH_DEFAULT_STORAGE_QUOTA, value: 1 },
],
deleteDelay30: [{ key: SystemConfigKey.USER_DELETE_DELAY, value: 30 }],
libraryWatchEnabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: true }],
libraryWatchDisabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: false }],
};

View File

@ -2,6 +2,7 @@
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { handleError } from '$lib/utils/handle-error';
import { deleteUser, type UserResponseDto } from '@immich/sdk';
import { serverConfig } from '$lib/stores/server-config.store';
import { createEventDispatcher } from 'svelte';
export let user: UserResponseDto;
@ -30,7 +31,7 @@
<svelte:fragment slot="prompt">
<div class="flex flex-col gap-4">
<p>
<b>{user.name}</b>'s account and assets will be permanently deleted after 7 days.
<b>{user.name}</b>'s account and assets will be permanently deleted after {$serverConfig.userDeleteDelay} days.
</p>
<p>Are you sure you want to continue?</p>
</div>

View File

@ -7,6 +7,7 @@
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { getConfig, getConfigDefaults, updateConfig, type SystemConfigDto } from '@immich/sdk';
import { loadConfig } from '$lib/stores/server-config.store';
import { cloneDeep } from 'lodash-es';
import { createEventDispatcher, onMount } from 'svelte';
import type { SettingsEventType } from './admin-settings';
@ -35,6 +36,8 @@
savedConfig = cloneDeep(newConfig);
notificationController.show({ message: 'Settings saved', type: NotificationType.Info });
await loadConfig();
dispatch('save');
} catch (error) {
handleError(error, 'Unable to save settings');

View File

@ -0,0 +1,45 @@
<script lang="ts">
import { type SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited
export let disabled = false;
const dispatch = createEventDispatcher<SettingsEventType>();
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
min={1}
label="DELETE DELAY"
desc="Number of days after removal to permanently delete a user's account and assets. The user deletion job runs at midnight to check for users that are ready for deletion. Changes to this setting will be evaluated at the next execution."
bind:value={config.user.deleteDelay}
isEdited={config.user.deleteDelay !== savedConfig.user.deleteDelay}
/>
</div>
<div class="ml-4">
<SettingButtonsRow
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['user'] })}
on:save={() => dispatch('save', { user: config.user })}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
{disabled}
/>
</div>
</form>
</div>
</div>

View File

@ -25,6 +25,7 @@ export const serverConfig = writable<ServerConfig>({
oauthButtonText: '',
loginPageMessage: '',
trashDays: 30,
userDeleteDelay: 7,
isInitialized: false,
isOnboarded: false,
externalDomain: '',

View File

@ -15,6 +15,7 @@
import ThemeSettings from '$lib/components/admin-page/settings/theme/theme-settings.svelte';
import ThumbnailSettings from '$lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte';
import TrashSettings from '$lib/components/admin-page/settings/trash-settings/trash-settings.svelte';
import UserSettings from '$lib/components/admin-page/settings/user-settings/user-settings.svelte';
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
@ -45,7 +46,8 @@
| typeof ThumbnailSettings
| typeof TrashSettings
| typeof NewVersionCheckSettings
| typeof FFmpegSettings;
| typeof FFmpegSettings
| typeof UserSettings;
const downloadConfig = () => {
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
@ -134,6 +136,12 @@
subtitle: 'Manage trash settings',
key: 'trash',
},
{
item: UserSettings,
title: 'User Settings',
subtitle: 'Manage user settings',
key: 'user-settings',
},
{
item: NewVersionCheckSettings,
title: 'Version Check',