Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
35 changes: 34 additions & 1 deletion api/src/unraid-api/auth/api-key.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -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<Error> if errors occur during the file deletion.
*/
public async deleteApiKeys(ids: string[]): Promise<void> {
// 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;
}
}
}
49 changes: 47 additions & 2 deletions api/src/unraid-api/cli/apikey/api-key.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,25 @@ 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[];
}

@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(
Expand Down Expand Up @@ -88,8 +91,50 @@ ACTIONS: ${Object.values(AuthActionVerb).join(', ')}`,
return description;
}

async run(_: string[], options: KeyOptions = { create: false, name: '' }): Promise<void> {
@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<DeleteApiKeyAnswers>(
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<void> {
try {
if (options.delete) {
await this.deleteKeys();
return;
}

const key = this.apiKeyService.findByField('name', options.name);
if (key) {
this.logger.log(key.key);
Expand Down
35 changes: 35 additions & 0 deletions api/src/unraid-api/cli/apikey/delete-api-key.questions.ts
Original file line number Diff line number Diff line change
@@ -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,
}));
}
}
2 changes: 2 additions & 0 deletions api/src/unraid-api/cli/cli.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -50,6 +51,7 @@ const DEFAULT_COMMANDS = [

const DEFAULT_PROVIDERS = [
AddApiKeyQuestionSet,
DeleteApiKeyQuestionSet,
AddSSOUserQuestionSet,
RemoveSSOUserQuestionSet,
DeveloperQuestions,
Expand Down
Loading