Added option to remove backups when uninstalling an app (#1756)

Co-authored-by: Nicolas Meienberger <47644445+meienberger@users.noreply.github.com>
This commit is contained in:
Sergio 2024-11-09 15:53:03 +02:00 committed by GitHub
parent 65c17bba67
commit 58234abe40
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 55 additions and 7 deletions

View File

@ -8,5 +8,5 @@ export default async () => {
["./modules/links/dto/links.dto"]: await import("./modules/links/dto/links.dto"), ["./modules/links/dto/links.dto"]: await import("./modules/links/dto/links.dto"),
["./modules/system/dto/system.dto"]: await import("./modules/system/dto/system.dto") ["./modules/system/dto/system.dto"]: await import("./modules/system/dto/system.dto")
}; };
return { "@nestjs/swagger": { "models": [[import("./modules/apps/dto/app-info.dto"), { "AppInfoSimpleDto": {}, "AppInfoDto": {} }], [import("./modules/user/dto/user.dto"), { "UserDto": {} }], [import("./app.dto"), { "UserSettingsDto": {}, "PartialUserSettingsDto": {}, "AppContextDto": {}, "UserContextDto": {}, "AcknowledgeWelcomeBody": {} }], [import("./modules/queue/queue.entity"), { "Queue": { queueNameResponse: { required: true, type: () => String } } }], [import("./modules/auth/dto/auth.dto"), { "LoginBody": {}, "VerifyTotpBody": {}, "LoginDto": {}, "RegisterBody": {}, "RegisterDto": {}, "ChangeUsernameBody": {}, "ChangePasswordBody": {}, "GetTotpUriBody": {}, "GetTotpUriDto": {}, "SetupTotpBody": {}, "DisableTotpBody": {}, "ResetPasswordBody": {}, "ResetPasswordDto": {}, "CheckResetPasswordRequestDto": {} }], [import("./modules/app-lifecycle/dto/app-lifecycle.dto"), { "AppFormBody": {} }], [import("./modules/apps/dto/app.dto"), { "SearchAppsQueryDto": {}, "SearchAppsDto": {}, "UpdateInfoDto": {}, "AppDto": {}, "MyAppsDto": {}, "GuestAppsDto": {}, "AppDetailsDto": {} }], [import("./modules/backups/dto/backups.dto"), { "BackupDto": {}, "RestoreAppBackupDto": {}, "GetAppBackupsDto": {}, "GetAppBackupsQueryDto": {}, "DeleteAppBackupBodyDto": {} }], [import("./modules/links/dto/links.dto"), { "LinkBodyDto": {}, "EditLinkBodyDto": {}, "LinksDto": {} }], [import("./modules/system/dto/system.dto"), { "LoadDto": {} }]], "controllers": [[import("./app.controller"), { "AppController": { "userContext": { type: t["./app.dto"].UserContextDto }, "appContext": { type: t["./app.dto"].AppContextDto }, "updateUserSettings": {}, "acknowledgeWelcome": {}, "getError": {} } }], [import("./modules/auth/auth.controller"), { "AuthController": { "login": { type: t["./modules/auth/dto/auth.dto"].LoginDto }, "verifyTotp": { type: t["./modules/auth/dto/auth.dto"].LoginDto }, "register": { type: t["./modules/auth/dto/auth.dto"].RegisterDto }, "logout": {}, "changeUsername": {}, "changePassword": {}, "getTotpUri": { type: t["./modules/auth/dto/auth.dto"].GetTotpUriDto }, "setupTotp": {}, "disableTotp": {}, "resetPassword": { type: t["./modules/auth/dto/auth.dto"].ResetPasswordDto }, "cancelResetPassword": {}, "checkResetPasswordRequest": { type: t["./modules/auth/dto/auth.dto"].CheckResetPasswordRequestDto } } }], [import("./modules/i18n/i18n.controller"), { "I18nController": { "getTranslation": { type: Object } } }], [import("./core/health/health.controller"), { "HealthController": { "check": { type: Object } } }], [import("./modules/apps/apps.controller"), { "AppsController": { "getInstalledApps": { type: t["./modules/apps/dto/app.dto"].MyAppsDto }, "getGuestApps": { type: t["./modules/apps/dto/app.dto"].GuestAppsDto }, "searchApps": { type: t["./modules/apps/dto/app.dto"].SearchAppsDto }, "getAppDetails": { type: t["./modules/apps/dto/app.dto"].AppDetailsDto }, "getImage": {} } }], [import("./modules/backups/backups.controller"), { "BackupsController": { "backupApp": {}, "restoreAppBackup": {}, "getAppBackups": { type: t["./modules/backups/dto/backups.dto"].GetAppBackupsDto }, "deleteAppBackup": {} } }], [import("./modules/app-lifecycle/app-lifecycle.controller"), { "AppLifecycleController": { "installApp": {}, "startApp": {}, "stopApp": {}, "restartApp": {}, "uninstallApp": {}, "resetApp": {} } }], [import("./modules/links/links.controller"), { "LinksController": { "getLinks": { type: t["./modules/links/dto/links.dto"].LinksDto }, "createLink": {}, "editLink": {}, "deleteLink": {} } }], [import("./modules/system/system.controller"), { "SystemController": { "systemLoad": { type: t["./modules/system/dto/system.dto"].LoadDto }, "downloadLocalCertificate": {} } }]] } }; return { "@nestjs/swagger": { "models": [[import("./modules/apps/dto/app-info.dto"), { "AppInfoSimpleDto": {}, "AppInfoDto": {} }], [import("./modules/user/dto/user.dto"), { "UserDto": {} }], [import("./app.dto"), { "UserSettingsDto": {}, "PartialUserSettingsDto": {}, "AppContextDto": {}, "UserContextDto": {}, "AcknowledgeWelcomeBody": {} }], [import("./modules/queue/queue.entity"), { "Queue": { queueNameResponse: { required: true, type: () => String } } }], [import("./modules/auth/dto/auth.dto"), { "LoginBody": {}, "VerifyTotpBody": {}, "LoginDto": {}, "RegisterBody": {}, "RegisterDto": {}, "ChangeUsernameBody": {}, "ChangePasswordBody": {}, "GetTotpUriBody": {}, "GetTotpUriDto": {}, "SetupTotpBody": {}, "DisableTotpBody": {}, "ResetPasswordBody": {}, "ResetPasswordDto": {}, "CheckResetPasswordRequestDto": {} }], [import("./modules/app-lifecycle/dto/app-lifecycle.dto"), { "AppFormBody": {}, "UninstallAppBody": {} }], [import("./modules/apps/dto/app.dto"), { "SearchAppsQueryDto": {}, "SearchAppsDto": {}, "UpdateInfoDto": {}, "AppDto": {}, "MyAppsDto": {}, "GuestAppsDto": {}, "AppDetailsDto": {} }], [import("./modules/backups/dto/backups.dto"), { "BackupDto": {}, "RestoreAppBackupDto": {}, "GetAppBackupsDto": {}, "GetAppBackupsQueryDto": {}, "DeleteAppBackupBodyDto": {} }], [import("./modules/links/dto/links.dto"), { "LinkBodyDto": {}, "EditLinkBodyDto": {}, "LinksDto": {} }], [import("./modules/system/dto/system.dto"), { "LoadDto": {} }]], "controllers": [[import("./app.controller"), { "AppController": { "userContext": { type: t["./app.dto"].UserContextDto }, "appContext": { type: t["./app.dto"].AppContextDto }, "updateUserSettings": {}, "acknowledgeWelcome": {}, "getError": {} } }], [import("./modules/auth/auth.controller"), { "AuthController": { "login": { type: t["./modules/auth/dto/auth.dto"].LoginDto }, "verifyTotp": { type: t["./modules/auth/dto/auth.dto"].LoginDto }, "register": { type: t["./modules/auth/dto/auth.dto"].RegisterDto }, "logout": {}, "changeUsername": {}, "changePassword": {}, "getTotpUri": { type: t["./modules/auth/dto/auth.dto"].GetTotpUriDto }, "setupTotp": {}, "disableTotp": {}, "resetPassword": { type: t["./modules/auth/dto/auth.dto"].ResetPasswordDto }, "cancelResetPassword": {}, "checkResetPasswordRequest": { type: t["./modules/auth/dto/auth.dto"].CheckResetPasswordRequestDto } } }], [import("./modules/i18n/i18n.controller"), { "I18nController": { "getTranslation": { type: Object } } }], [import("./core/health/health.controller"), { "HealthController": { "check": { type: Object } } }], [import("./modules/apps/apps.controller"), { "AppsController": { "getInstalledApps": { type: t["./modules/apps/dto/app.dto"].MyAppsDto }, "getGuestApps": { type: t["./modules/apps/dto/app.dto"].GuestAppsDto }, "searchApps": { type: t["./modules/apps/dto/app.dto"].SearchAppsDto }, "getAppDetails": { type: t["./modules/apps/dto/app.dto"].AppDetailsDto }, "getImage": {} } }], [import("./modules/backups/backups.controller"), { "BackupsController": { "backupApp": {}, "restoreAppBackup": {}, "getAppBackups": { type: t["./modules/backups/dto/backups.dto"].GetAppBackupsDto }, "deleteAppBackup": {} } }], [import("./modules/app-lifecycle/app-lifecycle.controller"), { "AppLifecycleController": { "installApp": {}, "startApp": {}, "stopApp": {}, "restartApp": {}, "uninstallApp": {}, "resetApp": {} } }], [import("./modules/links/links.controller"), { "LinksController": { "getLinks": { type: t["./modules/links/dto/links.dto"].LinksDto }, "createLink": {}, "editLink": {}, "deleteLink": {} } }], [import("./modules/system/system.controller"), { "SystemController": { "systemLoad": { type: t["./modules/system/dto/system.dto"].LoadDto }, "downloadLocalCertificate": {} } }]] } };
}; };

View File

@ -1,7 +1,7 @@
import { Body, Controller, Delete, Param, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Param, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard'; import { AuthGuard } from '../auth/auth.guard';
import { AppLifecycleService } from './app-lifecycle.service'; import { AppLifecycleService } from './app-lifecycle.service';
import { AppFormBody, appFormSchema } from './dto/app-lifecycle.dto'; import { AppFormBody, UninstallAppBody, appFormSchema } from './dto/app-lifecycle.dto';
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
@Controller('app-lifecycle') @Controller('app-lifecycle')
@ -31,8 +31,8 @@ export class AppLifecycleController {
} }
@Delete(':id/uninstall') @Delete(':id/uninstall')
async uninstallApp(@Param('id') id: string) { async uninstallApp(@Param('id') id: string, @Body() body: UninstallAppBody) {
return this.appLifecycleService.uninstallApp({ appId: id }); return this.appLifecycleService.uninstallApp({ appId: id, removeBackups: body.removeBackups });
} }
@Post(':id/reset') @Post(':id/reset')

View File

@ -8,6 +8,7 @@ import { isFQDN } from 'validator';
import type { z } from 'zod'; import type { z } from 'zod';
import { AppFilesManager } from '../apps/app-files-manager'; import { AppFilesManager } from '../apps/app-files-manager';
import { AppsRepository } from '../apps/apps.repository'; import { AppsRepository } from '../apps/apps.repository';
import { BackupManager } from '../backups/backup.manager';
import { type AppEventFormInput, AppEventsQueue, appEventSchema } from '../queue/entities/app-events'; import { type AppEventFormInput, AppEventsQueue, appEventSchema } from '../queue/entities/app-events';
import { AppLifecycleCommandFactory } from './app-lifecycle-command.factory'; import { AppLifecycleCommandFactory } from './app-lifecycle-command.factory';
@ -21,6 +22,7 @@ export class AppLifecycleService {
private readonly config: ConfigurationService, private readonly config: ConfigurationService,
private readonly appFilesManager: AppFilesManager, private readonly appFilesManager: AppFilesManager,
private readonly socketManager: SocketManager, private readonly socketManager: SocketManager,
private readonly backupManager: BackupManager,
) { ) {
this.logger.debug('Subscribing to app events...'); this.logger.debug('Subscribing to app events...');
this.appEventsQueue.onEvent(({ eventId, ...data }) => this.invokeCommand(eventId, data)); this.appEventsQueue.onEvent(({ eventId, ...data }) => this.invokeCommand(eventId, data));
@ -203,8 +205,8 @@ export class AppLifecycleService {
/** /**
* Uninstall an app by its ID * Uninstall an app by its ID
*/ */
public async uninstallApp(params: { appId: string }) { public async uninstallApp(params: { appId: string, removeBackups: boolean }) {
const { appId } = params; const { appId, removeBackups } = params;
const app = await this.appRepository.getApp(appId); const app = await this.appRepository.getApp(appId);
@ -212,6 +214,10 @@ export class AppLifecycleService {
throw new TranslatableError('APP_ERROR_APP_NOT_FOUND', { id: appId }); throw new TranslatableError('APP_ERROR_APP_NOT_FOUND', { id: appId });
} }
if (removeBackups) {
await this.backupManager.deleteAppBackupsById(appId);
}
await this.appRepository.updateApp(appId, { status: 'uninstalling' }); await this.appRepository.updateApp(appId, { status: 'uninstalling' });
this.socketManager.emit({ type: 'app', event: 'status_change', data: { appId, appStatus: 'uninstalling' } }); this.socketManager.emit({ type: 'app', event: 'status_change', data: { appId, appStatus: 'uninstalling' } });

View File

@ -13,3 +13,5 @@ export const appFormSchema = z
.catchall(z.unknown()); .catchall(z.unknown());
export class AppFormBody extends createZodDto(appFormSchema) {} export class AppFormBody extends createZodDto(appFormSchema) {}
export class UninstallAppBody extends createZodDto(z.object({ removeBackups: z.boolean() })) {}

View File

@ -117,6 +117,16 @@ export class BackupManager {
} }
} }
/**
* Delete all backups for an app
* @param appId - The app id
*/
public async deleteAppBackupsById(appId: string): Promise<void> {
const backups = await this.listBackupsByAppId(appId);
await Promise.all(backups.map((backup) => this.deleteBackup(appId, backup.id)))
}
/** /**
* List the backups for an app * List the backups for an app
* @param appId - The app id * @param appId - The app id

View File

@ -126,6 +126,7 @@
"APP_STORE_NO_RESULTS_SUBTITLE": "Try to refine your search", "APP_STORE_NO_RESULTS_SUBTITLE": "Try to refine your search",
"APP_STORE_SEARCH_PLACEHOLDER": "Search apps", "APP_STORE_SEARCH_PLACEHOLDER": "Search apps",
"APP_STORE_TITLE": "App Store", "APP_STORE_TITLE": "App Store",
"APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remove backups",
"APP_UNINSTALL_FORM_SUBMIT": "Uninstall", "APP_UNINSTALL_FORM_SUBMIT": "Uninstall",
"APP_UNINSTALL_FORM_SUBTITLE": "All data for this app will be lost.", "APP_UNINSTALL_FORM_SUBTITLE": "All data for this app will be lost.",
"APP_UNINSTALL_FORM_TITLE": "Uninstall {{name}} ?", "APP_UNINSTALL_FORM_TITLE": "Uninstall {{name}} ?",

View File

@ -668,6 +668,16 @@
} }
} }
], ],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UninstallAppBody"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "" "description": ""
@ -2542,6 +2552,17 @@
} }
} }
}, },
"UninstallAppBody": {
"type": "object",
"properties": {
"removeBackups": {
"type": "boolean"
}
},
"required": [
"removeBackups"
]
},
"RestoreAppBackupDto": { "RestoreAppBackupDto": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -758,6 +758,9 @@ export type UninstallAppData = {
path: { path: {
id: string; id: string;
}; };
body: {
removeBackups: boolean;
}
}; };
export type UninstallAppResponse = unknown; export type UninstallAppResponse = unknown;

View File

@ -1,11 +1,13 @@
import { uninstallAppMutation } from '@/api-client/@tanstack/react-query.gen'; import { uninstallAppMutation } from '@/api-client/@tanstack/react-query.gen';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
import { Switch } from '@/components/ui/Switch';
import { useAppStatus } from '@/modules/app/helpers/use-app-status'; import { useAppStatus } from '@/modules/app/helpers/use-app-status';
import type { AppInfo } from '@/types/app.types'; import type { AppInfo } from '@/types/app.types';
import type { TranslatableError } from '@/types/error.types'; import type { TranslatableError } from '@/types/error.types';
import { IconAlertTriangle } from '@tabler/icons-react'; import { IconAlertTriangle } from '@tabler/icons-react';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -19,6 +21,8 @@ export const UninstallDialog = ({ info, isOpen, onClose }: IProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { setOptimisticStatus } = useAppStatus(); const { setOptimisticStatus } = useAppStatus();
const [shouldRemoveBackups, setShouldRemoveBackups] = useState(false);
const uninstallMutation = useMutation({ const uninstallMutation = useMutation({
...uninstallAppMutation(), ...uninstallAppMutation(),
onError: (error: TranslatableError) => { onError: (error: TranslatableError) => {
@ -40,9 +44,10 @@ export const UninstallDialog = ({ info, isOpen, onClose }: IProps) => {
<IconAlertTriangle className="icon mb-2 text-danger icon-lg" /> <IconAlertTriangle className="icon mb-2 text-danger icon-lg" />
<h3>{t('APP_UNINSTALL_FORM_WARNING')}</h3> <h3>{t('APP_UNINSTALL_FORM_WARNING')}</h3>
<div className="text-muted">{t('APP_UNINSTALL_FORM_SUBTITLE')}</div> <div className="text-muted">{t('APP_UNINSTALL_FORM_SUBTITLE')}</div>
<Switch checked={shouldRemoveBackups} onCheckedChange={setShouldRemoveBackups} label={t('APP_UNINSTALL_FORM_REMOVE_BACKUPS')} className='text-start pt-5'/>
</DialogDescription> </DialogDescription>
<DialogFooter> <DialogFooter>
<Button onClick={() => uninstallMutation.mutate({ path: { id: info.id } })} intent="danger"> <Button onClick={() => uninstallMutation.mutate({ path: { id: info.id }, body: {removeBackups: shouldRemoveBackups} })} intent="danger">
{t('APP_UNINSTALL_FORM_SUBMIT')} {t('APP_UNINSTALL_FORM_SUBMIT')}
</Button> </Button>
</DialogFooter> </DialogFooter>