diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 777774d39c7..8dd33335a72 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -121,7 +121,7 @@ Flowise has 3 different modules in a single mono repository. Flowise support different environment variables to configure your instance. You can specify the following variables in the `.env` file inside `packages/server` folder. Read [more](https://docs.flowiseai.com/environment-variables) | Variable | Description | Type | Default | -| ---------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------ | ----------------------------------- | +| ---------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------ |-------------------------------------| | PORT | The HTTP port Flowise runs on | Number | 3000 | | CORS_ORIGINS | The allowed origins for all cross-origin HTTP calls | String | | | IFRAME_ORIGINS | The allowed origins for iframe src embedding | String | | @@ -133,7 +133,8 @@ Flowise support different environment variables to configure your instance. You | LOG_PATH | Location where log files are stored | String | `your-path/Flowise/logs` | | LOG_LEVEL | Different levels of logs | Enum String: `error`, `info`, `verbose`, `debug` | `info` | | LOG_JSON_SPACES | Spaces to beautify JSON logs | | 2 | -| APIKEY_PATH | Location where api keys are saved | String | `your-path/Flowise/packages/server` | +| APIKEY_STORAGE_TYPE | To store api keys on a JSON file or database. Default is `json` | Enum String: `json`, `db` | `json` | +| APIKEY_PATH | Location where api keys are saved when `APIKEY_STORAGE_TYPE` is `json` | String | `your-path/Flowise/packages/server` | | TOOL_FUNCTION_BUILTIN_DEP | NodeJS built-in modules to be used for Tool Function | String | | | TOOL_FUNCTION_EXTERNAL_DEP | External modules to be used for Tool Function | String | | | DATABASE_TYPE | Type of database to store the flowise data | Enum String: `sqlite`, `mysql`, `postgres` | `sqlite` | @@ -146,8 +147,8 @@ Flowise support different environment variables to configure your instance. You | DATABASE_SSL_KEY_BASE64 | Database SSL client cert in base64 (takes priority over DATABASE_SSL) | Boolean | false | | DATABASE_SSL | Database connection overssl (When DATABASE_TYPE is postgre) | Boolean | false | | SECRETKEY_PATH | Location where encryption key (used to encrypt/decrypt credentials) is saved | String | `your-path/Flowise/packages/server` | -| FLOWISE_SECRETKEY_OVERWRITE | Encryption key to be used instead of the key stored in SECRETKEY_PATH | String | -| DISABLE_FLOWISE_TELEMETRY | Turn off telemetry | Boolean | +| FLOWISE_SECRETKEY_OVERWRITE | Encryption key to be used instead of the key stored in SECRETKEY_PATH | String | | +| DISABLE_FLOWISE_TELEMETRY | Turn off telemetry | Boolean | | | MODEL_LIST_CONFIG_JSON | File path to load list of models from your local config file | String | `/your_model_list_config_file_path` | | STORAGE_TYPE | Type of storage for uploaded files. default is `local` | Enum String: `s3`, `local` | `local` | | BLOB_STORAGE_PATH | Local folder path where uploaded files are stored when `STORAGE_TYPE` is `local` | String | `your-home-dir/.flowise/storage` | diff --git a/docker/.env.example b/docker/.env.example index 173c3205636..9013d728138 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -47,4 +47,6 @@ BLOB_STORAGE_PATH=/root/.flowise/storage # S3_STORAGE_ACCESS_KEY_ID= # S3_STORAGE_SECRET_ACCESS_KEY= # S3_STORAGE_REGION=us-west-2 -# S3_ENDPOINT_URL= \ No newline at end of file +# S3_ENDPOINT_URL= + +# APIKEY_STORAGE_TYPE=json (json | db) diff --git a/i18n/CONTRIBUTING-ZH.md b/i18n/CONTRIBUTING-ZH.md index 84e9c9a5285..af518ce4144 100644 --- a/i18n/CONTRIBUTING-ZH.md +++ b/i18n/CONTRIBUTING-ZH.md @@ -128,7 +128,8 @@ Flowise 支持不同的环境变量来配置您的实例。您可以在 `package | DEBUG | 打印组件的日志 | 布尔值 | | | LOG_PATH | 存储日志文件的位置 | 字符串 | `your-path/Flowise/logs` | | LOG_LEVEL | 日志的不同级别 | 枚举字符串: `error`, `info`, `verbose`, `debug` | `info` | -| APIKEY_PATH | 存储 API 密钥的位置 | 字符串 | `your-path/Flowise/packages/server` | +| APIKEY_STORAGE_TYPE | 存储 API 密钥的存储类型 | 枚举字符串: `json`, `db` | `json` | +| APIKEY_PATH | 存储 API 密钥的位置, 当`APIKEY_STORAGE_TYPE`是`json` | 字符串 | `your-path/Flowise/packages/server` | | TOOL_FUNCTION_BUILTIN_DEP | 用于工具函数的 NodeJS 内置模块 | 字符串 | | | TOOL_FUNCTION_EXTERNAL_DEP | 用于工具函数的外部模块 | 字符串 | | | DATABASE_TYPE | 存储 flowise 数据的数据库类型 | 枚举字符串: `sqlite`, `mysql`, `postgres` | `sqlite` | diff --git a/packages/server/.env.example b/packages/server/.env.example index b322c760a41..70129804876 100644 --- a/packages/server/.env.example +++ b/packages/server/.env.example @@ -47,4 +47,6 @@ PORT=3000 # S3_STORAGE_ACCESS_KEY_ID= # S3_STORAGE_SECRET_ACCESS_KEY= # S3_STORAGE_REGION=us-west-2 -# S3_ENDPOINT_URL= \ No newline at end of file +# S3_ENDPOINT_URL= + +# APIKEY_STORAGE_TYPE=json (json | db) diff --git a/packages/server/src/AppConfig.ts b/packages/server/src/AppConfig.ts new file mode 100644 index 00000000000..b96e3ac17f9 --- /dev/null +++ b/packages/server/src/AppConfig.ts @@ -0,0 +1,6 @@ +export const appConfig = { + apiKeys: { + storageType: process.env.APIKEY_STORAGE_TYPE ? process.env.APIKEY_STORAGE_TYPE.toLowerCase() : 'json' + } + // todo: add more config options here like database, log, storage, credential and allow modification from UI +} diff --git a/packages/server/src/Interface.ts b/packages/server/src/Interface.ts index e8fb84a8256..8d975666245 100644 --- a/packages/server/src/Interface.ts +++ b/packages/server/src/Interface.ts @@ -263,5 +263,13 @@ export interface IUploadFileSizeAndTypes { maxUploadSize: number } +export interface IApiKey { + id: string + keyName: string + apiKey: string + apiSecret: string + updatedDate: Date +} + // DocumentStore related export * from './Interface.DocumentStore' diff --git a/packages/server/src/commands/start.ts b/packages/server/src/commands/start.ts index e537a30666d..7d7a2e6307e 100644 --- a/packages/server/src/commands/start.ts +++ b/packages/server/src/commands/start.ts @@ -24,6 +24,7 @@ export default class Start extends Command { IFRAME_ORIGINS: Flags.string(), DEBUG: Flags.string(), BLOB_STORAGE_PATH: Flags.string(), + APIKEY_STORAGE_TYPE: Flags.string(), APIKEY_PATH: Flags.string(), SECRETKEY_PATH: Flags.string(), FLOWISE_SECRETKEY_OVERWRITE: Flags.string(), @@ -100,6 +101,7 @@ export default class Start extends Command { // Authorization if (flags.FLOWISE_USERNAME) process.env.FLOWISE_USERNAME = flags.FLOWISE_USERNAME if (flags.FLOWISE_PASSWORD) process.env.FLOWISE_PASSWORD = flags.FLOWISE_PASSWORD + if (flags.APIKEY_STORAGE_TYPE) process.env.APIKEY_STORAGE_TYPE = flags.APIKEY_STORAGE_TYPE if (flags.APIKEY_PATH) process.env.APIKEY_PATH = flags.APIKEY_PATH // API Configuration diff --git a/packages/server/src/controllers/apikey/index.ts b/packages/server/src/controllers/apikey/index.ts index a2448e14537..40452b71999 100644 --- a/packages/server/src/controllers/apikey/index.ts +++ b/packages/server/src/controllers/apikey/index.ts @@ -41,6 +41,19 @@ const updateApiKey = async (req: Request, res: Response, next: NextFunction) => } } +// Import Keys from JSON file +const importKeys = async (req: Request, res: Response, next: NextFunction) => { + try { + if (typeof req.body === 'undefined' || !req.body.jsonFile) { + throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, `Error: apikeyController.importKeys - body not provided!`) + } + const apiResponse = await apikeyService.importKeys(req.body) + return res.json(apiResponse) + } catch (error) { + next(error) + } +} + // Delete api key const deleteApiKey = async (req: Request, res: Response, next: NextFunction) => { try { @@ -72,5 +85,6 @@ export default { deleteApiKey, getAllApiKeys, updateApiKey, - verifyApiKey + verifyApiKey, + importKeys } diff --git a/packages/server/src/controllers/chatflows/index.ts b/packages/server/src/controllers/chatflows/index.ts index 61ba0691d70..523a6572739 100644 --- a/packages/server/src/controllers/chatflows/index.ts +++ b/packages/server/src/controllers/chatflows/index.ts @@ -1,11 +1,11 @@ import { NextFunction, Request, Response } from 'express' import { StatusCodes } from 'http-status-codes' +import apiKeyService from '../../services/apikey' import { ChatFlow } from '../../database/entities/ChatFlow' +import { createRateLimiter } from '../../utils/rateLimit' import { InternalFlowiseError } from '../../errors/internalFlowiseError' import { ChatflowType } from '../../Interface' import chatflowsService from '../../services/chatflows' -import { getApiKey } from '../../utils/apiKey' -import { createRateLimiter } from '../../utils/rateLimit' const checkIfChatflowIsValidForStreaming = async (req: Request, res: Response, next: NextFunction) => { try { @@ -67,7 +67,7 @@ const getChatflowByApiKey = async (req: Request, res: Response, next: NextFuncti `Error: chatflowsRouter.getChatflowByApiKey - apikey not provided!` ) } - const apikey = await getApiKey(req.params.apikey) + const apikey = await apiKeyService.getApiKey(req.params.apikey) if (!apikey) { return res.status(401).send('Unauthorized') } diff --git a/packages/server/src/database/entities/ApiKey.ts b/packages/server/src/database/entities/ApiKey.ts new file mode 100644 index 00000000000..d96610df20a --- /dev/null +++ b/packages/server/src/database/entities/ApiKey.ts @@ -0,0 +1,21 @@ +import { Column, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm' +import { IApiKey } from '../../Interface' + +@Entity('apikey') +export class ApiKey implements IApiKey { + @PrimaryColumn({ type: 'varchar', length: 20 }) + id: string + + @Column({ type: 'text' }) + apiKey: string + + @Column({ type: 'text' }) + apiSecret: string + + @Column({ type: 'text' }) + keyName: string + + @Column({ type: 'timestamp' }) + @UpdateDateColumn() + updatedDate: Date +} diff --git a/packages/server/src/database/entities/index.ts b/packages/server/src/database/entities/index.ts index 738c25d782f..0e715e68b5b 100644 --- a/packages/server/src/database/entities/index.ts +++ b/packages/server/src/database/entities/index.ts @@ -9,6 +9,7 @@ import { DocumentStore } from './DocumentStore' import { DocumentStoreFileChunk } from './DocumentStoreFileChunk' import { Lead } from './Lead' import { UpsertHistory } from './UpsertHistory' +import { ApiKey } from './ApiKey' export const entities = { ChatFlow, @@ -21,5 +22,6 @@ export const entities = { DocumentStore, DocumentStoreFileChunk, Lead, - UpsertHistory + UpsertHistory, + ApiKey } diff --git a/packages/server/src/database/migrations/mariadb/1720230151480-AddApiKey.ts b/packages/server/src/database/migrations/mariadb/1720230151480-AddApiKey.ts new file mode 100644 index 00000000000..5f2a923090b --- /dev/null +++ b/packages/server/src/database/migrations/mariadb/1720230151480-AddApiKey.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddApiKey1720230151480 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS \`apikey\` ( + \`id\` varchar(36) NOT NULL, + \`apiKey\` varchar(255) NOT NULL, + \`apiSecret\` varchar(255) NOT NULL, + \`keyName\` varchar(255) NOT NULL, + \`updatedDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE apikey`) + } +} diff --git a/packages/server/src/database/migrations/mariadb/index.ts b/packages/server/src/database/migrations/mariadb/index.ts index c231a94dcfd..1a058b30201 100644 --- a/packages/server/src/database/migrations/mariadb/index.ts +++ b/packages/server/src/database/migrations/mariadb/index.ts @@ -20,6 +20,7 @@ import { AddLead1710832127079 } from './1710832127079-AddLead' import { AddLeadToChatMessage1711538023578 } from './1711538023578-AddLeadToChatMessage' import { AddAgentReasoningToChatMessage1714679514451 } from './1714679514451-AddAgentReasoningToChatMessage' import { AddTypeToChatFlow1766759476232 } from './1766759476232-AddTypeToChatFlow' +import { AddApiKey1720230151480 } from './1720230151480-AddApiKey' import { AddActionToChatMessage1721078251523 } from './1721078251523-AddActionToChatMessage' export const mariadbMigrations = [ @@ -45,5 +46,6 @@ export const mariadbMigrations = [ AddLeadToChatMessage1711538023578, AddAgentReasoningToChatMessage1714679514451, AddTypeToChatFlow1766759476232, + AddApiKey1720230151480, AddActionToChatMessage1721078251523 ] diff --git a/packages/server/src/database/migrations/mysql/1720230151480-AddApiKey.ts b/packages/server/src/database/migrations/mysql/1720230151480-AddApiKey.ts new file mode 100644 index 00000000000..a264607c55f --- /dev/null +++ b/packages/server/src/database/migrations/mysql/1720230151480-AddApiKey.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddApiKey1720230151480 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS \`apikey\` ( + \`id\` varchar(36) NOT NULL, + \`apiKey\` varchar(255) NOT NULL, + \`apiSecret\` varchar(255) NOT NULL, + \`keyName\` varchar(255) NOT NULL, + \`updatedDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE apikey`) + } +} diff --git a/packages/server/src/database/migrations/mysql/index.ts b/packages/server/src/database/migrations/mysql/index.ts index ebd68336606..662457cb4f1 100644 --- a/packages/server/src/database/migrations/mysql/index.ts +++ b/packages/server/src/database/migrations/mysql/index.ts @@ -20,6 +20,7 @@ import { AddLead1710832127079 } from './1710832127079-AddLead' import { AddLeadToChatMessage1711538023578 } from './1711538023578-AddLeadToChatMessage' import { AddAgentReasoningToChatMessage1714679514451 } from './1714679514451-AddAgentReasoningToChatMessage' import { AddTypeToChatFlow1766759476232 } from './1766759476232-AddTypeToChatFlow' +import { AddApiKey1720230151480 } from './1720230151480-AddApiKey' import { AddActionToChatMessage1721078251523 } from './1721078251523-AddActionToChatMessage' export const mysqlMigrations = [ @@ -45,5 +46,6 @@ export const mysqlMigrations = [ AddLeadToChatMessage1711538023578, AddAgentReasoningToChatMessage1714679514451, AddTypeToChatFlow1766759476232, + AddApiKey1720230151480, AddActionToChatMessage1721078251523 ] diff --git a/packages/server/src/database/migrations/postgres/1720230151480-AddApiKey.ts b/packages/server/src/database/migrations/postgres/1720230151480-AddApiKey.ts new file mode 100644 index 00000000000..a32f37564d3 --- /dev/null +++ b/packages/server/src/database/migrations/postgres/1720230151480-AddApiKey.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddApiKey1720230151480 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS apikey ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + "apiKey" varchar NOT NULL, + "apiSecret" varchar NOT NULL, + "keyName" varchar NOT NULL, + "updatedDate" timestamp NOT NULL DEFAULT now(), + CONSTRAINT "PK_96109043dd704f53-9830ab78f0" PRIMARY KEY (id) + );` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE apikey`) + } +} diff --git a/packages/server/src/database/migrations/postgres/index.ts b/packages/server/src/database/migrations/postgres/index.ts index 10836deca10..5334b4fac42 100644 --- a/packages/server/src/database/migrations/postgres/index.ts +++ b/packages/server/src/database/migrations/postgres/index.ts @@ -21,6 +21,7 @@ import { AddLead1710832137905 } from './1710832137905-AddLead' import { AddLeadToChatMessage1711538016098 } from './1711538016098-AddLeadToChatMessage' import { AddAgentReasoningToChatMessage1714679514451 } from './1714679514451-AddAgentReasoningToChatMessage' import { AddTypeToChatFlow1766759476232 } from './1766759476232-AddTypeToChatFlow' +import { AddApiKey1720230151480 } from './1720230151480-AddApiKey' import { AddActionToChatMessage1721078251523 } from './1721078251523-AddActionToChatMessage' export const postgresMigrations = [ @@ -47,5 +48,6 @@ export const postgresMigrations = [ AddLeadToChatMessage1711538016098, AddAgentReasoningToChatMessage1714679514451, AddTypeToChatFlow1766759476232, + AddApiKey1720230151480, AddActionToChatMessage1721078251523 ] diff --git a/packages/server/src/database/migrations/sqlite/1720230151480-AddApiKey.ts b/packages/server/src/database/migrations/sqlite/1720230151480-AddApiKey.ts new file mode 100644 index 00000000000..8d836a9ab08 --- /dev/null +++ b/packages/server/src/database/migrations/sqlite/1720230151480-AddApiKey.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddApiKey1720230151480 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS "apikey" ("id" varchar PRIMARY KEY NOT NULL, + "apiKey" varchar NOT NULL, + "apiSecret" varchar NOT NULL, + "keyName" varchar NOT NULL, + "updatedDate" datetime NOT NULL DEFAULT (datetime('now')));` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "apikey";`) + } +} diff --git a/packages/server/src/database/migrations/sqlite/index.ts b/packages/server/src/database/migrations/sqlite/index.ts index cdcd02d8151..44a602c4266 100644 --- a/packages/server/src/database/migrations/sqlite/index.ts +++ b/packages/server/src/database/migrations/sqlite/index.ts @@ -21,6 +21,7 @@ import { AddLeadToChatMessage1711537986113 } from './1711537986113-AddLeadToChat import { AddAgentReasoningToChatMessage1714679514451 } from './1714679514451-AddAgentReasoningToChatMessage' import { AddTypeToChatFlow1766759476232 } from './1766759476232-AddTypeToChatFlow' import { AddActionToChatMessage1721078251523 } from './1721078251523-AddActionToChatMessage' +import { AddApiKey1720230151480 } from './1720230151480-AddApiKey' export const sqliteMigrations = [ Init1693835579790, @@ -45,5 +46,6 @@ export const sqliteMigrations = [ AddLeadToChatMessage1711537986113, AddAgentReasoningToChatMessage1714679514451, AddTypeToChatFlow1766759476232, + AddApiKey1720230151480, AddActionToChatMessage1721078251523 ] diff --git a/packages/server/src/routes/apikey/index.ts b/packages/server/src/routes/apikey/index.ts index 566d12e8bf1..dbc043dd59e 100644 --- a/packages/server/src/routes/apikey/index.ts +++ b/packages/server/src/routes/apikey/index.ts @@ -4,6 +4,7 @@ const router = express.Router() // CREATE router.post('/', apikeyController.createApiKey) +router.post('/import', apikeyController.importKeys) // READ router.get('/', apikeyController.getAllApiKeys) diff --git a/packages/server/src/services/apikey/index.ts b/packages/server/src/services/apikey/index.ts index dd519612ee7..84ebac0a7dc 100644 --- a/packages/server/src/services/apikey/index.ts +++ b/packages/server/src/services/apikey/index.ts @@ -1,25 +1,94 @@ import { StatusCodes } from 'http-status-codes' -import { addAPIKey, deleteAPIKey, getAPIKeys, updateAPIKey } from '../../utils/apiKey' +import { + addAPIKey as addAPIKey_json, + deleteAPIKey as deleteAPIKey_json, + generateAPIKey, + generateSecretHash, + getApiKey as getApiKey_json, + getAPIKeys as getAPIKeys_json, + updateAPIKey as updateAPIKey_json, + replaceAllAPIKeys as replaceAllAPIKeys_json, + importKeys as importKeys_json +} from '../../utils/apiKey' import { addChatflowsCount } from '../../utils/addChatflowsCount' -import { getApiKey } from '../../utils/apiKey' import { InternalFlowiseError } from '../../errors/internalFlowiseError' import { getErrorMessage } from '../../errors/utils' +import { getRunningExpressApp } from '../../utils/getRunningExpressApp' +import { ApiKey } from '../../database/entities/ApiKey' +import { appConfig } from '../../AppConfig' +import { randomBytes } from 'crypto' +import { Not, IsNull } from 'typeorm' + +const _apikeysStoredInJson = (): boolean => { + return appConfig.apiKeys.storageType === 'json' +} + +const _apikeysStoredInDb = (): boolean => { + return appConfig.apiKeys.storageType === 'db' +} const getAllApiKeys = async () => { try { - const keys = await getAPIKeys() - const dbResponse = await addChatflowsCount(keys) - return dbResponse + if (_apikeysStoredInJson()) { + const keys = await getAPIKeys_json() + return await addChatflowsCount(keys) + } else if (_apikeysStoredInDb()) { + const appServer = getRunningExpressApp() + let keys = await appServer.AppDataSource.getRepository(ApiKey).find() + if (keys.length === 0) { + await createApiKey('DefaultKey') + keys = await appServer.AppDataSource.getRepository(ApiKey).find() + } + return await addChatflowsCount(keys) + } else { + throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, `UNKNOWN APIKEY_STORAGE_TYPE`) + } } catch (error) { throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, `Error: apikeyService.getAllApiKeys - ${getErrorMessage(error)}`) } } +const getApiKey = async (keyName: string) => { + try { + if (_apikeysStoredInJson()) { + return getApiKey_json(keyName) + } else if (_apikeysStoredInDb()) { + const appServer = getRunningExpressApp() + const currentKey = await appServer.AppDataSource.getRepository(ApiKey).findOneBy({ + keyName: keyName + }) + if (!currentKey) { + return undefined + } + return currentKey + } else { + throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, `UNKNOWN APIKEY_STORAGE_TYPE`) + } + } catch (error) { + throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, `Error: apikeyService.createApiKey - ${getErrorMessage(error)}`) + } +} + const createApiKey = async (keyName: string) => { try { - const keys = await addAPIKey(keyName) - const dbResponse = await addChatflowsCount(keys) - return dbResponse + if (_apikeysStoredInJson()) { + const keys = await addAPIKey_json(keyName) + return await addChatflowsCount(keys) + } else if (_apikeysStoredInDb()) { + const apiKey = generateAPIKey() + const apiSecret = generateSecretHash(apiKey) + const appServer = getRunningExpressApp() + const newKey = new ApiKey() + newKey.id = randomBytes(16).toString('hex') + newKey.apiKey = apiKey + newKey.apiSecret = apiSecret + newKey.keyName = keyName + const key = appServer.AppDataSource.getRepository(ApiKey).create(newKey) + await appServer.AppDataSource.getRepository(ApiKey).save(key) + return getAllApiKeys() + } else { + throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, `UNKNOWN APIKEY_STORAGE_TYPE`) + } } catch (error) { throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, `Error: apikeyService.createApiKey - ${getErrorMessage(error)}`) } @@ -28,9 +97,23 @@ const createApiKey = async (keyName: string) => { // Update api key const updateApiKey = async (id: string, keyName: string) => { try { - const keys = await updateAPIKey(id, keyName) - const dbResponse = await addChatflowsCount(keys) - return dbResponse + if (_apikeysStoredInJson()) { + const keys = await updateAPIKey_json(id, keyName) + return await addChatflowsCount(keys) + } else if (_apikeysStoredInDb()) { + const appServer = getRunningExpressApp() + const currentKey = await appServer.AppDataSource.getRepository(ApiKey).findOneBy({ + id: id + }) + if (!currentKey) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `ApiKey ${currentKey} not found`) + } + currentKey.keyName = keyName + await appServer.AppDataSource.getRepository(ApiKey).save(currentKey) + return getAllApiKeys() + } else { + throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, `UNKNOWN APIKEY_STORAGE_TYPE`) + } } catch (error) { throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, `Error: apikeyService.updateApiKey - ${getErrorMessage(error)}`) } @@ -38,22 +121,123 @@ const updateApiKey = async (id: string, keyName: string) => { const deleteApiKey = async (id: string) => { try { - const keys = await deleteAPIKey(id) - const dbResponse = await addChatflowsCount(keys) - return dbResponse + if (_apikeysStoredInJson()) { + const keys = await deleteAPIKey_json(id) + return await addChatflowsCount(keys) + } else if (_apikeysStoredInDb()) { + const appServer = getRunningExpressApp() + const dbResponse = await appServer.AppDataSource.getRepository(ApiKey).delete({ id: id }) + if (!dbResponse) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `ApiKey ${id} not found`) + } + return getAllApiKeys() + } else { + throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, `UNKNOWN APIKEY_STORAGE_TYPE`) + } } catch (error) { throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, `Error: apikeyService.deleteApiKey - ${getErrorMessage(error)}`) } } +const importKeys = async (body: any) => { + try { + const jsonFile = body.jsonFile + const splitDataURI = jsonFile.split(',') + if (splitDataURI[0] !== 'data:application/json;base64') { + throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, `Invalid dataURI`) + } + const bf = Buffer.from(splitDataURI[1] || '', 'base64') + const plain = bf.toString('utf8') + const keys = JSON.parse(plain) + if (_apikeysStoredInJson()) { + if (body.importMode === 'replaceAll') { + await replaceAllAPIKeys_json(keys) + } else { + await importKeys_json(keys, body.importMode) + } + return await addChatflowsCount(keys) + } else if (_apikeysStoredInDb()) { + const appServer = getRunningExpressApp() + const allApiKeys = await appServer.AppDataSource.getRepository(ApiKey).find() + if (body.importMode === 'replaceAll') { + await appServer.AppDataSource.getRepository(ApiKey).delete({ + id: Not(IsNull()) + }) + } + if (body.importMode === 'errorIfExist') { + // if importMode is errorIfExist, check for existing keys and raise error before any modification to the DB + for (const key of keys) { + const keyNameExists = allApiKeys.find((k) => k.keyName === key.keyName) + if (keyNameExists) { + throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, `Key with name ${key.keyName} already exists`) + } + } + } + // iterate through the keys and add them to the database + for (const key of keys) { + const keyNameExists = allApiKeys.find((k) => k.keyName === key.keyName) + if (keyNameExists) { + const keyIndex = allApiKeys.findIndex((k) => k.keyName === key.keyName) + switch (body.importMode) { + case 'overwriteIfExist': { + const currentKey = allApiKeys[keyIndex] + currentKey.id = key.id + currentKey.apiKey = key.apiKey + currentKey.apiSecret = key.apiSecret + await appServer.AppDataSource.getRepository(ApiKey).save(currentKey) + break + } + case 'ignoreIfExist': { + // ignore this key and continue + continue + } + case 'errorIfExist': { + // should not reach here as we have already checked for existing keys + throw new Error(`Key with name ${key.keyName} already exists`) + } + default: { + throw new Error(`Unknown overwrite option ${body.importMode}`) + } + } + } else { + const newKey = new ApiKey() + newKey.id = key.id + newKey.apiKey = key.apiKey + newKey.apiSecret = key.apiSecret + newKey.keyName = key.keyName + const newKeyEntity = appServer.AppDataSource.getRepository(ApiKey).create(newKey) + await appServer.AppDataSource.getRepository(ApiKey).save(newKeyEntity) + } + } + return getAllApiKeys() + } else { + throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, `UNKNOWN APIKEY_STORAGE_TYPE`) + } + } catch (error) { + throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, `Error: apikeyService.importKeys - ${getErrorMessage(error)}`) + } +} + const verifyApiKey = async (paramApiKey: string): Promise => { try { - const apiKey = await getApiKey(paramApiKey) - if (!apiKey) { - throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, `Unauthorized`) + if (_apikeysStoredInJson()) { + const apiKey = await getApiKey_json(paramApiKey) + if (!apiKey) { + throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, `Unauthorized`) + } + return 'OK' + } else if (_apikeysStoredInDb()) { + const appServer = getRunningExpressApp() + const apiKey = await appServer.AppDataSource.getRepository(ApiKey).findOneBy({ + apiKey: paramApiKey + }) + if (!apiKey) { + throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, `Unauthorized`) + } + return 'OK' + } else { + throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, `UNKNOWN APIKEY_STORAGE_TYPE`) } - const dbResponse = 'OK' - return dbResponse } catch (error) { if (error instanceof InternalFlowiseError && error.statusCode === StatusCodes.UNAUTHORIZED) { throw error @@ -71,5 +255,7 @@ export default { deleteApiKey, getAllApiKeys, updateApiKey, - verifyApiKey + verifyApiKey, + getApiKey, + importKeys } diff --git a/packages/server/src/utils/apiKey.ts b/packages/server/src/utils/apiKey.ts index 57239825bdb..a50b2b54a85 100644 --- a/packages/server/src/utils/apiKey.ts +++ b/packages/server/src/utils/apiKey.ts @@ -4,6 +4,7 @@ import moment from 'moment' import fs from 'fs' import path from 'path' import logger from './logger' +import { appConfig } from '../AppConfig' /** * Returns the api key path @@ -50,6 +51,9 @@ export const compareKeys = (storedKey: string, suppliedKey: string): boolean => * @returns {Promise} */ export const getAPIKeys = async (): Promise => { + if (appConfig.apiKeys.storageType !== 'json') { + return [] + } try { const content = await fs.promises.readFile(getAPIKeyPath(), 'utf8') return JSON.parse(content) @@ -94,6 +98,47 @@ export const addAPIKey = async (keyName: string): Promise => { return content } +/** + * import API keys + * @param {[]} keys + * @returns {Promise} + */ +export const importKeys = async (keys: any[], importMode: string): Promise => { + const allApiKeys = await getAPIKeys() + // if importMode is errorIfExist, check for existing keys and raise error before any modification to the file + if (importMode === 'errorIfExist') { + for (const key of keys) { + const keyNameExists = allApiKeys.find((k) => k.keyName === key.keyName) + if (keyNameExists) { + throw new Error(`Key with name ${key.keyName} already exists`) + } + } + } + for (const key of keys) { + // Check if keyName already exists, if overwrite is false, raise an error else overwrite the key + const keyNameExists = allApiKeys.find((k) => k.keyName === key.keyName) + if (keyNameExists) { + const keyIndex = allApiKeys.findIndex((k) => k.keyName === key.keyName) + switch (importMode) { + case 'overwriteIfExist': + allApiKeys[keyIndex] = key + continue + case 'ignoreIfExist': + // ignore this key and continue + continue + case 'errorIfExist': + // should not reach here as we have already checked for existing keys + throw new Error(`Key with name ${key.keyName} already exists`) + default: + throw new Error(`Unknown overwrite option ${importMode}`) + } + } + allApiKeys.push(key) + } + await fs.promises.writeFile(getAPIKeyPath(), JSON.stringify(allApiKeys), 'utf8') + return allApiKeys +} + /** * Get API Key details * @param {string} apiKey diff --git a/packages/server/src/utils/validateKey.ts b/packages/server/src/utils/validateKey.ts index 02d36cf28da..3c4f272ca83 100644 --- a/packages/server/src/utils/validateKey.ts +++ b/packages/server/src/utils/validateKey.ts @@ -1,7 +1,7 @@ import { Request } from 'express' import { ChatFlow } from '../database/entities/ChatFlow' -import { getAPIKeys, compareKeys } from './apiKey' - +import { compareKeys } from './apiKey' +import apikeyService from '../services/apikey' /** * Validate API Key * @param {Request} req @@ -17,8 +17,8 @@ export const utilValidateKey = async (req: Request, chatflow: ChatFlow) => { const suppliedKey = authorizationHeader.split(`Bearer `).pop() if (suppliedKey) { - const keys = await getAPIKeys() - const apiSecret = keys.find((key) => key.id === chatFlowApiKeyId)?.apiSecret + const keys = await apikeyService.getAllApiKeys() + const apiSecret = keys.find((key: any) => key.id === chatFlowApiKeyId)?.apiSecret if (!compareKeys(apiSecret, suppliedKey)) return false return true } diff --git a/packages/ui/src/api/apikey.js b/packages/ui/src/api/apikey.js index aed0a2d5f23..ca554d574cb 100644 --- a/packages/ui/src/api/apikey.js +++ b/packages/ui/src/api/apikey.js @@ -8,9 +8,12 @@ const updateAPI = (id, body) => client.put(`/apikey/${id}`, body) const deleteAPI = (id) => client.delete(`/apikey/${id}`) +const importAPI = (body) => client.post(`/apikey/import`, body) + export default { getAllAPIKeys, createNewAPI, updateAPI, - deleteAPI + deleteAPI, + importAPI } diff --git a/packages/ui/src/views/apikey/UploadJSONFileDialog.jsx b/packages/ui/src/views/apikey/UploadJSONFileDialog.jsx new file mode 100644 index 00000000000..4d39d895a43 --- /dev/null +++ b/packages/ui/src/views/apikey/UploadJSONFileDialog.jsx @@ -0,0 +1,186 @@ +import { createPortal } from 'react-dom' +import PropTypes from 'prop-types' +import { useState, useEffect } from 'react' +import { useDispatch } from 'react-redux' +import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction } from '@/store/actions' + +// Material +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Box, Typography, Stack } from '@mui/material' + +// Project imports +import { StyledButton } from '@/ui-component/button/StyledButton' +import ConfirmDialog from '@/ui-component/dialog/ConfirmDialog' +import { File } from '@/ui-component/file/File' + +// Icons +import { IconFileUpload, IconX } from '@tabler/icons-react' + +// API +import apikeyAPI from '@/api/apikey' + +// utils +import useNotifier from '@/utils/useNotifier' + +// const +import { HIDE_CANVAS_DIALOG, SHOW_CANVAS_DIALOG } from '@/store/actions' +import { Dropdown } from '@/ui-component/dropdown/Dropdown' + +const importModes = [ + { + label: 'Add & Overwrite', + name: 'overwriteIfExist', + description: 'Add keys and overwrite existing keys with the same name' + }, + { + label: 'Add & Ignore', + name: 'ignoreIfExist', + description: 'Add keys and ignore existing keys with the same name' + }, + { + label: 'Add & Verify', + name: 'errorIfExist', + description: 'Add Keys and throw error if key with same name exists' + }, + { + label: 'Replace All', + name: 'replaceAll', + description: 'Replace all keys with the imported keys' + } +] + +const UploadJSONFileDialog = ({ show, dialogProps, onCancel, onConfirm }) => { + const portalElement = document.getElementById('portal') + + const dispatch = useDispatch() + + // ==============================|| Snackbar ||============================== // + + useNotifier() + + const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args)) + const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args)) + + const [selectedFile, setSelectedFile] = useState() + const [importMode, setImportMode] = useState('overwrite') + + useEffect(() => { + return () => { + setSelectedFile() + } + }, [dialogProps]) + + useEffect(() => { + if (show) dispatch({ type: SHOW_CANVAS_DIALOG }) + else dispatch({ type: HIDE_CANVAS_DIALOG }) + return () => dispatch({ type: HIDE_CANVAS_DIALOG }) + }, [show, dispatch]) + + const importKeys = async () => { + try { + const obj = { + importMode: importMode, + jsonFile: selectedFile + } + const createResp = await apikeyAPI.importAPI(obj) + if (createResp.data) { + enqueueSnackbar({ + message: 'Imported keys successfully!', + options: { + key: new Date().getTime() + Math.random(), + variant: 'success', + action: (key) => ( + + ) + } + }) + onConfirm(createResp.data.id) + } + } catch (error) { + enqueueSnackbar({ + message: `Failed to import keys: ${ + typeof error.response.data === 'object' ? error.response.data.message : error.response.data + }`, + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + onCancel() + } + } + + const component = show ? ( + + +
+ + Import API Keys +
+
+ + + + + Import api.json file +  * + + + setSelectedFile(newValue)} + value={selectedFile ?? 'Choose a file to upload'} + /> + + + + + Import Mode +  * + + + setImportMode(newValue)} + value={importMode ?? 'choose an option'} + /> + + + + + + {dialogProps.confirmButtonName} + + + +
+ ) : null + + return createPortal(component, portalElement) +} + +UploadJSONFileDialog.propTypes = { + show: PropTypes.bool, + dialogProps: PropTypes.object, + onCancel: PropTypes.func, + onConfirm: PropTypes.func +} + +export default UploadJSONFileDialog diff --git a/packages/ui/src/views/apikey/index.jsx b/packages/ui/src/views/apikey/index.jsx index d0c3bd5665e..56bc3ecfedd 100644 --- a/packages/ui/src/views/apikey/index.jsx +++ b/packages/ui/src/views/apikey/index.jsx @@ -44,8 +44,20 @@ import useConfirm from '@/hooks/useConfirm' import useNotifier from '@/utils/useNotifier' // Icons -import { IconTrash, IconEdit, IconCopy, IconChevronsUp, IconChevronsDown, IconX, IconPlus, IconEye, IconEyeOff } from '@tabler/icons-react' +import { + IconTrash, + IconEdit, + IconCopy, + IconChevronsUp, + IconChevronsDown, + IconX, + IconPlus, + IconEye, + IconEyeOff, + IconFileUpload +} from '@tabler/icons-react' import APIEmptySVG from '@/assets/images/api_empty.svg' +import UploadJSONFileDialog from '@/views/apikey/UploadJSONFileDialog' // ==============================|| APIKey ||============================== // @@ -200,6 +212,9 @@ const APIKey = () => { const [showApiKeys, setShowApiKeys] = useState([]) const openPopOver = Boolean(anchorEl) + const [showUploadDialog, setShowUploadDialog] = useState(false) + const [uploadDialogProps, setUploadDialogProps] = useState({}) + const [search, setSearch] = useState('') const onSearchChange = (event) => { setSearch(event.target.value) @@ -254,6 +269,17 @@ const APIKey = () => { setShowDialog(true) } + const uploadDialog = () => { + const dialogProp = { + type: 'ADD', + cancelButtonName: 'Cancel', + confirmButtonName: 'Upload', + data: {} + } + setUploadDialogProps(dialogProp) + setShowUploadDialog(true) + } + const deleteKey = async (key) => { const confirmPayload = { title: `Delete`, @@ -308,6 +334,7 @@ const APIKey = () => { const onConfirm = () => { setShowDialog(false) + setShowUploadDialog(false) getAllAPIKeysApi.request() } @@ -341,6 +368,15 @@ const APIKey = () => { ) : ( + { onConfirm={onConfirm} setError={setError} > + {showUploadDialog && ( + setShowUploadDialog(false)} + onConfirm={onConfirm} + > + )} )