From 2f09445f2ed6b23cd851ca64ac5b84cfde3cbd50 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Fri, 28 Mar 2025 10:17:22 -0400 Subject: [PATCH] feat(api): add `unraid-api --delete` command (#1289) - Added a `command:raw` package.json script to let devs run `dist/cli.js` without rebuilding, enabling experimentation in the transpiled script. - `unraid-api --delete` allows users to select and remove keys with confirmation prompts. --- api/package.json | 1 + api/src/unraid-api/auth/api-key.service.ts | 35 ++++++++++++- .../unraid-api/cli/apikey/api-key.command.ts | 49 ++++++++++++++++++- .../cli/apikey/delete-api-key.questions.ts | 35 +++++++++++++ api/src/unraid-api/cli/cli.module.ts | 2 + 5 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 api/src/unraid-api/cli/apikey/delete-api-key.questions.ts diff --git a/api/package.json b/api/package.json index 02baffc2d..8ab10d4e0 100644 --- a/api/package.json +++ b/api/package.json @@ -17,6 +17,7 @@ "start": "node dist/main.js", "dev": "vite", "command": "pnpm run build && clear && ./dist/cli.js", + "command:raw": "./dist/cli.js", "// Build and Deploy": "", "build": "vite build --mode=production", "postbuild": "chmod +x dist/main.js && chmod +x dist/cli.js", diff --git a/api/src/unraid-api/auth/api-key.service.ts b/api/src/unraid-api/auth/api-key.service.ts index a1285586d..7ac827322 100644 --- a/api/src/unraid-api/auth/api-key.service.ts +++ b/api/src/unraid-api/auth/api-key.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import crypto from 'crypto'; -import { readdir, readFile, writeFile } from 'fs/promises'; +import { readdir, readFile, unlink, writeFile } from 'fs/promises'; import { join } from 'path'; import { watch } from 'chokidar'; @@ -23,6 +23,7 @@ import { import { getters, store } from '@app/store/index.js'; import { setLocalApiKey } from '@app/store/modules/config.js'; import { FileLoadStatus } from '@app/store/types.js'; +import { batchProcess } from '@app/utils.js'; @Injectable() export class ApiKeyService implements OnModuleInit { @@ -312,4 +313,36 @@ export class ApiKeyService implements OnModuleInit { basePath: this.basePath, }; } + + /** + * Deletes API keys from the disk and updates the in-memory store. + * + * This method first verifies that all the provided API key IDs exist in the in-memory store. + * If any keys are missing, it throws an Error detailing the missing keys. + * It then deletes the corresponding JSON files concurrently using batch processing. + * If any errors occur during the file deletion process, an array of errors is thrown. + * + * @param ids An array of API key identifiers to delete. + * @throws Error if one or more API keys are not found. + * @throws Array if errors occur during the file deletion. + */ + public async deleteApiKeys(ids: string[]): Promise { + // First verify all keys exist + const missingKeys = ids.filter((id) => !this.findByField('id', id)); + if (missingKeys.length > 0) { + throw new Error(`API keys not found: ${missingKeys.join(', ')}`); + } + + // Delete all files in parallel + const { errors, data: deletedIds } = await batchProcess(ids, async (id) => { + await unlink(join(this.basePath, `${id}.json`)); + return id; + }); + + const deletedSet = new Set(deletedIds); + this.memoryApiKeys = this.memoryApiKeys.filter((key) => !deletedSet.has(key.id)); + if (errors.length > 0) { + throw errors; + } + } } diff --git a/api/src/unraid-api/cli/apikey/api-key.command.ts b/api/src/unraid-api/cli/apikey/api-key.command.ts index 4d900f280..340488c2a 100644 --- a/api/src/unraid-api/cli/apikey/api-key.command.ts +++ b/api/src/unraid-api/cli/apikey/api-key.command.ts @@ -2,14 +2,17 @@ import { AuthActionVerb } from 'nest-authz'; import { Command, CommandRunner, InquirerService, Option } from 'nest-commander'; import type { Permission } from '@app/graphql/generated/api/types.js'; +import type { DeleteApiKeyAnswers } from '@app/unraid-api/cli/apikey/delete-api-key.questions.js'; import { Resource, Role } from '@app/graphql/generated/api/types.js'; import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js'; import { AddApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/add-api-key.questions.js'; +import { DeleteApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/delete-api-key.questions.js'; import { LogService } from '@app/unraid-api/cli/log.service.js'; interface KeyOptions { name: string; create: boolean; + delete?: boolean; description?: string; roles?: Role[]; permissions?: Permission[]; @@ -17,7 +20,7 @@ interface KeyOptions { @Command({ name: 'apikey', - description: `Create / Fetch Connect API Keys - use --create with no arguments for a creation wizard`, + description: `Create / Fetch / Delete Connect API Keys - use --create with no arguments for a creation wizard, or --delete to remove keys`, }) export class ApiKeyCommand extends CommandRunner { constructor( @@ -88,8 +91,50 @@ ACTIONS: ${Object.values(AuthActionVerb).join(', ')}`, return description; } - async run(_: string[], options: KeyOptions = { create: false, name: '' }): Promise { + @Option({ + flags: '--delete', + description: 'Delete selected API keys', + }) + parseDelete(): boolean { + return true; + } + + /** Prompt the user to select API keys to delete. Then, delete the selected keys. */ + private async deleteKeys() { + const allKeys = this.apiKeyService.findAll(); + if (allKeys.length === 0) { + this.logger.log('No API keys found to delete'); + return; + } + + const answers = await this.inquirerService.prompt( + DeleteApiKeyQuestionSet.name, + {} + ); + if (!answers.selectedKeys || answers.selectedKeys.length === 0) { + this.logger.log('No keys selected for deletion'); + return; + } + try { + await this.apiKeyService.deleteApiKeys(answers.selectedKeys); + this.logger.log(`Successfully deleted ${answers.selectedKeys.length} API keys`); + } catch (error) { + this.logger.error(error as any); + process.exit(1); + } + } + + async run( + _: string[], + options: KeyOptions = { create: false, name: '', delete: false } + ): Promise { + try { + if (options.delete) { + await this.deleteKeys(); + return; + } + const key = this.apiKeyService.findByField('name', options.name); if (key) { this.logger.log(key.key); diff --git a/api/src/unraid-api/cli/apikey/delete-api-key.questions.ts b/api/src/unraid-api/cli/apikey/delete-api-key.questions.ts new file mode 100644 index 000000000..74c1623fd --- /dev/null +++ b/api/src/unraid-api/cli/apikey/delete-api-key.questions.ts @@ -0,0 +1,35 @@ +import { ChoicesFor, Question, QuestionSet } from 'nest-commander'; + +import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js'; +import { LogService } from '@app/unraid-api/cli/log.service.js'; + +export interface DeleteApiKeyAnswers { + selectedKeys: string[]; +} + +@QuestionSet({ name: 'delete-api-key' }) +export class DeleteApiKeyQuestionSet { + constructor( + private readonly apiKeyService: ApiKeyService, + private readonly logger: LogService + ) {} + + static name = 'delete-api-key'; + + @Question({ + name: 'selectedKeys', + type: 'checkbox', + message: 'Select API keys to delete:', + }) + parseSelectedKeys(keyIds: string[]): string[] { + return keyIds; + } + + @ChoicesFor({ name: 'selectedKeys' }) + async getKeys() { + return this.apiKeyService.findAll().map((key) => ({ + name: `${key.name} (${key.description ?? ''}) [${key.id}]`, + value: key.id, + })); + } +} diff --git a/api/src/unraid-api/cli/cli.module.ts b/api/src/unraid-api/cli/cli.module.ts index 7e5d4e9d1..203051c54 100644 --- a/api/src/unraid-api/cli/cli.module.ts +++ b/api/src/unraid-api/cli/cli.module.ts @@ -5,6 +5,7 @@ import { CommandRunner } from 'nest-commander'; import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js'; import { AddApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/add-api-key.questions.js'; import { ApiKeyCommand } from '@app/unraid-api/cli/apikey/api-key.command.js'; +import { DeleteApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/delete-api-key.questions.js'; import { ConfigCommand } from '@app/unraid-api/cli/config.command.js'; import { DeveloperCommand } from '@app/unraid-api/cli/developer/developer.command.js'; import { DeveloperQuestions } from '@app/unraid-api/cli/developer/developer.questions.js'; @@ -50,6 +51,7 @@ const DEFAULT_COMMANDS = [ const DEFAULT_PROVIDERS = [ AddApiKeyQuestionSet, + DeleteApiKeyQuestionSet, AddSSOUserQuestionSet, RemoveSSOUserQuestionSet, DeveloperQuestions,