Add support for azure blob storage and storage refactor (#5604)

* storageUtils functions azure storage

* interface, base and local storage provider

* s3 storage provider

* gcs, azure storage provider

* refactor final changes

* fix gcs path bug

* gemini and lint fixes

* documentation changes

* update pnpm lock

* update azure winston to ESM imports

---------

Co-authored-by: Henry <[email protected]>
This commit is contained in:
chiragjagga
2026-03-04 21:58:08 +08:00
committed by GitHub
co-authored by Henry
parent 4d857f88c5
commit 07ef4bbec8
24 changed files with 2749 additions and 1504 deletions
+44 -40
View File
@@ -120,46 +120,50 @@ 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 | |
| FLOWISE_FILE_SIZE_LIMIT | Upload File Size Limit | String | 50mb |
| DEBUG | Print logs from components | Boolean | |
| 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 |
| TOOL_FUNCTION_BUILTIN_DEP | NodeJS built-in modules to be used for Custom Tool or Function | String | |
| TOOL_FUNCTION_EXTERNAL_DEP | External modules to be used for Custom Tool or Function | String | |
| ALLOW_BUILTIN_DEP | Allow project dependencies to be used for Custom Tool or Function | Boolean | false |
| DATABASE_TYPE | Type of database to store the flowise data | Enum String: `sqlite`, `mysql`, `postgres` | `sqlite` |
| DATABASE_PATH | Location where database is saved (When DATABASE_TYPE is sqlite) | String | `your-home-dir/.flowise` |
| DATABASE_HOST | Host URL or IP address (When DATABASE_TYPE is not sqlite) | String | |
| DATABASE_PORT | Database port (When DATABASE_TYPE is not sqlite) | String | |
| DATABASE_USER | Database username (When DATABASE_TYPE is not sqlite) | String | |
| DATABASE_PASSWORD | Database password (When DATABASE_TYPE is not sqlite) | String | |
| DATABASE_NAME | Database name (When DATABASE_TYPE is not sqlite) | String | |
| 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 | |
| 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`, `gcs` | `local` |
| BLOB_STORAGE_PATH | Local folder path where uploaded files are stored when `STORAGE_TYPE` is `local` | String | `your-home-dir/.flowise/storage` |
| S3_STORAGE_BUCKET_NAME | Bucket name to hold the uploaded files when `STORAGE_TYPE` is `s3` | String | |
| S3_STORAGE_ACCESS_KEY_ID | AWS Access Key | String | |
| S3_STORAGE_SECRET_ACCESS_KEY | AWS Secret Key | String | |
| S3_STORAGE_REGION | Region for S3 bucket | String | |
| S3_ENDPOINT_URL | Custom Endpoint for S3 | String | |
| S3_FORCE_PATH_STYLE | Set this to true to force the request to use path-style addressing | Boolean | false |
| GOOGLE_CLOUD_STORAGE_PROJ_ID | The GCP project id for cloud storage & logging when `STORAGE_TYPE` is `gcs` | String | |
| GOOGLE_CLOUD_STORAGE_CREDENTIAL | The credential key file path when `STORAGE_TYPE` is `gcs` | String | |
| GOOGLE_CLOUD_STORAGE_BUCKET_NAME | Bucket name to hold the uploaded files when `STORAGE_TYPE` is `gcs` | String | |
| GOOGLE_CLOUD_UNIFORM_BUCKET_ACCESS | Enable uniform bucket level access when `STORAGE_TYPE` is `gcs` | Boolean | true |
| SHOW_COMMUNITY_NODES | Show nodes created by community | Boolean | |
| DISABLED_NODES | Hide nodes from UI (comma separated list of node names) | String | |
| TRUST_PROXY | Configure proxy trust settings for proper IP detection. Values: 'true' (trust all), 'false' (disable), number (hop count), or Express proxy values (e.g., 'loopback', 'linklocal', 'uniquelocal', IP addresses). [Learn More](https://expressjs.com/en/guide/behind-proxies.html) | Boolean/String/Number | true |
| 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 | |
| FLOWISE_FILE_SIZE_LIMIT | Upload File Size Limit | String | 50mb |
| DEBUG | Print logs from components | Boolean | |
| 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 |
| TOOL_FUNCTION_BUILTIN_DEP | NodeJS built-in modules to be used for Custom Tool or Function | String | |
| TOOL_FUNCTION_EXTERNAL_DEP | External modules to be used for Custom Tool or Function | String | |
| ALLOW_BUILTIN_DEP | Allow project dependencies to be used for Custom Tool or Function | Boolean | false |
| DATABASE_TYPE | Type of database to store the flowise data | Enum String: `sqlite`, `mysql`, `postgres` | `sqlite` |
| DATABASE_PATH | Location where database is saved (When DATABASE_TYPE is sqlite) | String | `your-home-dir/.flowise` |
| DATABASE_HOST | Host URL or IP address (When DATABASE_TYPE is not sqlite) | String | |
| DATABASE_PORT | Database port (When DATABASE_TYPE is not sqlite) | String | |
| DATABASE_USER | Database username (When DATABASE_TYPE is not sqlite) | String | |
| DATABASE_PASSWORD | Database password (When DATABASE_TYPE is not sqlite) | String | |
| DATABASE_NAME | Database name (When DATABASE_TYPE is not sqlite) | String | |
| 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 | |
| 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`, `gcs` ,`azure` | `local` |
| BLOB_STORAGE_PATH | Local folder path where uploaded files are stored when `STORAGE_TYPE` is `local` | String | `your-home-dir/.flowise/storage` |
| S3_STORAGE_BUCKET_NAME | Bucket name to hold the uploaded files when `STORAGE_TYPE` is `s3` | String | |
| S3_STORAGE_ACCESS_KEY_ID | AWS Access Key | String | |
| S3_STORAGE_SECRET_ACCESS_KEY | AWS Secret Key | String | |
| S3_STORAGE_REGION | Region for S3 bucket | String | |
| S3_ENDPOINT_URL | Custom Endpoint for S3 | String | |
| S3_FORCE_PATH_STYLE | Set this to true to force the request to use path-style addressing | Boolean | false |
| GOOGLE_CLOUD_STORAGE_PROJ_ID | The GCP project id for cloud storage & logging when `STORAGE_TYPE` is `gcs` | String | |
| GOOGLE_CLOUD_STORAGE_CREDENTIAL | The credential key file path when `STORAGE_TYPE` is `gcs` | String | |
| GOOGLE_CLOUD_STORAGE_BUCKET_NAME | Bucket name to hold the uploaded files when `STORAGE_TYPE` is `gcs` | String | |
| GOOGLE_CLOUD_UNIFORM_BUCKET_ACCESS | Enable uniform bucket level access when `STORAGE_TYPE` is `gcs` | Boolean | true |
| AZURE_BLOB_STORAGE_CONNECTION_STRING | Azure Blob Storage connection string when `STORAGE_TYPE` is `azure`. Either this or account name + key is required | String | |
| AZURE_BLOB_STORAGE_ACCOUNT_NAME | Azure storage account name when `STORAGE_TYPE` is `azure`. Required if connection string is not provided | String | |
| AZURE_BLOB_STORAGE_ACCOUNT_KEY | Azure storage account key when `STORAGE_TYPE` is `azure`. Required if connection string is not provided | String | |
| AZURE_BLOB_STORAGE_CONTAINER_NAME | Container name to hold the uploaded files when `STORAGE_TYPE` is `azure` | String | |
| SHOW_COMMUNITY_NODES | Show nodes created by community | Boolean | |
| DISABLED_NODES | Hide nodes from UI (comma separated list of node names) | String | |
| TRUST_PROXY | Configure proxy trust settings for proper IP detection. Values: 'true' (trust all), 'false' (disable), number (hop count), or Express proxy values (e.g., 'loopback', 'linklocal', 'uniquelocal', IP addresses). [Learn More](https://expressjs.com/en/guide/behind-proxies.html) | Boolean/String/Number | true |
You can also specify the env variables when using `npx`. For example:
+7 -1
View File
@@ -49,7 +49,7 @@ PORT=3000
############################################## STORAGE #####################################################
############################################################################################################
# STORAGE_TYPE=local (local | s3 | gcs)
# STORAGE_TYPE=local (local | s3 | gcs | azure)
# BLOB_STORAGE_PATH=/your_storage_path/.flowise/storage
# S3_STORAGE_BUCKET_NAME=flowise
# S3_STORAGE_ACCESS_KEY_ID=<your-access-key>
@@ -61,6 +61,12 @@ PORT=3000
# GOOGLE_CLOUD_STORAGE_PROJ_ID=<your-gcp-project-id>
# GOOGLE_CLOUD_STORAGE_BUCKET_NAME=<the-bucket-name>
# GOOGLE_CLOUD_UNIFORM_BUCKET_ACCESS=true
# Azure Blob Storage (when STORAGE_TYPE=azure)
# Provide EITHER a connection string OR account name + key
# AZURE_BLOB_STORAGE_CONNECTION_STRING=<your-connection-string>
# AZURE_BLOB_STORAGE_ACCOUNT_NAME=<your-account-name>
# AZURE_BLOB_STORAGE_ACCOUNT_KEY=<your-account-key>
# AZURE_BLOB_STORAGE_CONTAINER_NAME=<your-container-name>
############################################################################################################
+5
View File
@@ -67,6 +67,11 @@ services:
- GOOGLE_CLOUD_STORAGE_PROJ_ID=${GOOGLE_CLOUD_STORAGE_PROJ_ID}
- GOOGLE_CLOUD_STORAGE_BUCKET_NAME=${GOOGLE_CLOUD_STORAGE_BUCKET_NAME}
- GOOGLE_CLOUD_UNIFORM_BUCKET_ACCESS=${GOOGLE_CLOUD_UNIFORM_BUCKET_ACCESS}
# Azure Blob Storage (provide EITHER connection string OR account name + key)
- AZURE_BLOB_STORAGE_CONNECTION_STRING=${AZURE_BLOB_STORAGE_CONNECTION_STRING}
- AZURE_BLOB_STORAGE_ACCOUNT_NAME=${AZURE_BLOB_STORAGE_ACCOUNT_NAME}
- AZURE_BLOB_STORAGE_ACCOUNT_KEY=${AZURE_BLOB_STORAGE_ACCOUNT_KEY}
- AZURE_BLOB_STORAGE_CONTAINER_NAME=${AZURE_BLOB_STORAGE_CONTAINER_NAME}
# SETTINGS
- NUMBER_OF_PROXIES=${NUMBER_OF_PROXIES}
+5
View File
@@ -52,6 +52,11 @@ services:
- GOOGLE_CLOUD_STORAGE_PROJ_ID=${GOOGLE_CLOUD_STORAGE_PROJ_ID}
- GOOGLE_CLOUD_STORAGE_BUCKET_NAME=${GOOGLE_CLOUD_STORAGE_BUCKET_NAME}
- GOOGLE_CLOUD_UNIFORM_BUCKET_ACCESS=${GOOGLE_CLOUD_UNIFORM_BUCKET_ACCESS}
# Azure Blob Storage (provide EITHER connection string OR account name + key)
- AZURE_BLOB_STORAGE_CONNECTION_STRING=${AZURE_BLOB_STORAGE_CONNECTION_STRING}
- AZURE_BLOB_STORAGE_ACCOUNT_NAME=${AZURE_BLOB_STORAGE_ACCOUNT_NAME}
- AZURE_BLOB_STORAGE_ACCOUNT_KEY=${AZURE_BLOB_STORAGE_ACCOUNT_KEY}
- AZURE_BLOB_STORAGE_CONTAINER_NAME=${AZURE_BLOB_STORAGE_CONTAINER_NAME}
# SETTINGS
- NUMBER_OF_PROXIES=${NUMBER_OF_PROXIES}
+7 -1
View File
@@ -49,7 +49,7 @@ WORKER_PORT=5566
############################################## STORAGE #####################################################
############################################################################################################
# STORAGE_TYPE=local (local | s3 | gcs)
# STORAGE_TYPE=local (local | s3 | gcs | azure)
# BLOB_STORAGE_PATH=/your_storage_path/.flowise/storage
# S3_STORAGE_BUCKET_NAME=flowise
# S3_STORAGE_ACCESS_KEY_ID=<your-access-key>
@@ -61,6 +61,12 @@ WORKER_PORT=5566
# GOOGLE_CLOUD_STORAGE_PROJ_ID=<your-gcp-project-id>
# GOOGLE_CLOUD_STORAGE_BUCKET_NAME=<the-bucket-name>
# GOOGLE_CLOUD_UNIFORM_BUCKET_ACCESS=true
# Azure Blob Storage (when STORAGE_TYPE=azure)
# Provide EITHER a connection string OR account name + key
# AZURE_BLOB_STORAGE_CONNECTION_STRING=<your-connection-string>
# AZURE_BLOB_STORAGE_ACCOUNT_NAME=<your-account-name>
# AZURE_BLOB_STORAGE_ACCOUNT_KEY=<your-account-key>
# AZURE_BLOB_STORAGE_CONTAINER_NAME=<your-container-name>
############################################################################################################
+5
View File
@@ -52,6 +52,11 @@ services:
- GOOGLE_CLOUD_STORAGE_PROJ_ID=${GOOGLE_CLOUD_STORAGE_PROJ_ID}
- GOOGLE_CLOUD_STORAGE_BUCKET_NAME=${GOOGLE_CLOUD_STORAGE_BUCKET_NAME}
- GOOGLE_CLOUD_UNIFORM_BUCKET_ACCESS=${GOOGLE_CLOUD_UNIFORM_BUCKET_ACCESS}
# Azure Blob Storage (provide EITHER connection string OR account name + key)
- AZURE_BLOB_STORAGE_CONNECTION_STRING=${AZURE_BLOB_STORAGE_CONNECTION_STRING}
- AZURE_BLOB_STORAGE_ACCOUNT_NAME=${AZURE_BLOB_STORAGE_ACCOUNT_NAME}
- AZURE_BLOB_STORAGE_ACCOUNT_KEY=${AZURE_BLOB_STORAGE_ACCOUNT_KEY}
- AZURE_BLOB_STORAGE_CONTAINER_NAME=${AZURE_BLOB_STORAGE_CONTAINER_NAME}
# SETTINGS
- NUMBER_OF_PROXIES=${NUMBER_OF_PROXIES}
+207 -207
View File
@@ -1,207 +1,207 @@
# @flowiseai/agentflow
[![Version](https://img.shields.io/npm/v/@flowiseai/agentflow)](https://www.npmjs.com/package/@flowiseai/agentflow)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md)
> Embeddable React component for building and visualizing AI agent workflows
## ⚠️ Status
**This package is currently under active development.**
- 🚧 Components are not yet fully functional
- ❌ End-to-end functionality is not complete
- 🔄 Features are still being implemented and tested
- ⚡ APIs may change before stable release
- 📝 Documentation is being updated as development progresses
**Cannot be used in production. For development and testing purposes only.**
## Overview
`@flowiseai/agentflow` is a React-based flow editor for creating AI agent workflows. It provides a visual canvas built on ReactFlow for connecting AI agents, LLMs, tools, and logic nodes.
## Installation
```bash
pnpm add @flowiseai/agentflow
```
**Peer Dependencies:**
```bash
pnpm add react react-dom @mui/material @mui/icons-material @emotion/react @emotion/styled reactflow
```
## Basic Usage
```tsx
import { Agentflow } from '@flowiseai/agentflow'
import '@flowiseai/agentflow/flowise.css'
export default function App() {
return (
<div style={{ width: '100vw', height: '100vh' }}>
<Agentflow apiBaseUrl='http://localhost:3000' token='your-api-key' />
</div>
)
}
```
### With Initial Flow Data and Callbacks
```tsx
import { useRef } from 'react'
import { Agentflow, type AgentFlowInstance, type FlowData } from '@flowiseai/agentflow'
import '@flowiseai/agentflow/flowise.css'
export default function App() {
const ref = useRef<AgentFlowInstance>(null)
const initialFlow: FlowData = {
nodes: [
{
id: 'startAgentflow_0',
type: 'agentflowNode',
position: { x: 100, y: 100 },
data: {
id: 'startAgentflow_0',
name: 'startAgentflow',
label: 'Start',
color: '#7EE787',
hideInput: true,
outputAnchors: [{ id: 'startAgentflow_0-output-0', name: 'start', label: 'Start', type: 'start' }]
}
}
],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 }
}
return (
<div style={{ width: '100vw', height: '100vh' }}>
<Agentflow
ref={ref}
apiBaseUrl='http://localhost:3000'
token='your-api-key'
initialFlow={initialFlow}
onFlowChange={(flow) => console.log('Flow changed:', flow)}
onSave={(flow) => console.log('Flow saved:', flow)}
/>
</div>
)
}
```
## Props
| Prop | Type | Default | Description |
| -------------------- | ------------------------------------------ | -------------- | --------------------------------------------------------------- |
| `apiBaseUrl` | `string` | **(required)** | Flowise API server endpoint |
| `token` | `string` | — | Authentication token for API calls |
| `initialFlow` | `FlowData` | — | Initial flow data to render (uncontrolled — only used on mount) |
| `components` | `string[]` | — | Restrict which node types appear in the palette |
| `onFlowChange` | `(flow: FlowData) => void` | — | Called when the flow changes (node/edge add, remove, move) |
| `onSave` | `(flow: FlowData) => void` | — | Called when the user triggers a save |
| `onFlowGenerated` | `(flow: FlowData) => void` | — | Called when a flow is generated via AI |
| `isDarkMode` | `boolean` | `false` | Use dark mode theme |
| `readOnly` | `boolean` | `false` | Disable editing (nodes not draggable/connectable) |
| `showDefaultHeader` | `boolean` | `true` | Show built-in header (ignored if `renderHeader` provided) |
| `showDefaultPalette` | `boolean` | `true` | Show built-in node palette |
| `enableGenerator` | `boolean` | `true` | Show the AI flow generator button |
| `renderHeader` | `(props: HeaderRenderProps) => ReactNode` | — | Custom header renderer |
| `renderNodePalette` | `(props: PaletteRenderProps) => ReactNode` | — | Custom node palette renderer |
### Imperative Methods (via `ref`)
| Method | Return Type | Description |
| ------------------------ | ------------------------- | ------------------------------------- |
| `getFlow()` | `FlowData` | Get current flow data |
| `toJSON()` | `string` | Export flow as JSON string |
| `validate()` | `ValidationResult` | Validate the current flow |
| `fitView()` | `void` | Fit all nodes into view |
| `clear()` | `void` | Remove all nodes and edges |
| `addNode(nodeData)` | `void` | Add a node (`Partial<FlowNode>`) |
| `getReactFlowInstance()` | `ReactFlowInstance\|null` | Get the underlying ReactFlow instance |
### Design Note
`<Agentflow>` is an **uncontrolled component**. The `initialFlow` prop seeds the canvas state on mount, but the component owns its own state afterward. Use the `ref` for imperative access and `onFlowChange` to observe changes.
## Development
```bash
# Install dependencies
pnpm install
# Build the package
pnpm build
# Run tests
pnpm test
# Run examples
cd examples && pnpm install && pnpm dev
```
Visit the [examples](./examples) directory for more usage patterns. See [TESTS.md](./TESTS.md) for the full test plan and coverage status.
## Publishing
### Version Update
Bump the version in `package.json` before publishing. Use `npm version` to update the version and create a git tag:
```bash
# Prerelease (for testing)
npm version prerelease --preid=dev # 0.0.0-dev.1 → 0.0.0-dev.2
# Patch / Minor / Major (for stable releases)
npm version patch # 0.0.1
npm version minor # 0.1.0
npm version major # 1.0.0
```
### Verify Before Publishing
```bash
# Build and check the tarball contents
pnpm build
npm pack --dry-run
# Full publish dry-run (runs prepublishOnly + simulates upload)
npm publish --dry-run
```
### Publish
```bash
# Prerelease — tagged so `npm install @flowiseai/agentflow` won't pick it up
npm publish --tag dev
# Stable release — gets the `latest` tag
npm publish
```
> The `prepublishOnly` script automatically runs `clean` and `build` before every publish, so stale dist files are never uploaded.
## Documentation
- [ARCHITECTURE.md](./ARCHITECTURE.md) - Internal architecture and design patterns
- [TESTS.md](./TESTS.md) - Test plan, coverage tiers, and configuration
- [Examples](./examples/README.md) - Usage examples and demos
## Contributing
This package follows a feature-based architecture with clear separation of concerns. See [ARCHITECTURE.md](./ARCHITECTURE.md) for details on the project structure and development guidelines.
## License
Apache-2.0 — see the repository root [LICENSE.md](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md) for details.
---
Part of the [Flowise](https://github.com/FlowiseAI/Flowise) ecosystem
# @flowiseai/agentflow
[![Version](https://img.shields.io/npm/v/@flowiseai/agentflow)](https://www.npmjs.com/package/@flowiseai/agentflow)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md)
> Embeddable React component for building and visualizing AI agent workflows
## ⚠️ Status
**This package is currently under active development.**
- 🚧 Components are not yet fully functional
- ❌ End-to-end functionality is not complete
- 🔄 Features are still being implemented and tested
- ⚡ APIs may change before stable release
- 📝 Documentation is being updated as development progresses
**Cannot be used in production. For development and testing purposes only.**
## Overview
`@flowiseai/agentflow` is a React-based flow editor for creating AI agent workflows. It provides a visual canvas built on ReactFlow for connecting AI agents, LLMs, tools, and logic nodes.
## Installation
```bash
pnpm add @flowiseai/agentflow
```
**Peer Dependencies:**
```bash
pnpm add react react-dom @mui/material @mui/icons-material @emotion/react @emotion/styled reactflow
```
## Basic Usage
```tsx
import { Agentflow } from '@flowiseai/agentflow'
import '@flowiseai/agentflow/flowise.css'
export default function App() {
return (
<div style={{ width: '100vw', height: '100vh' }}>
<Agentflow apiBaseUrl='http://localhost:3000' token='your-api-key' />
</div>
)
}␍
```
### With Initial Flow Data and Callbacks
```tsx
import { useRef } from 'react'
import { Agentflow, type AgentFlowInstance, type FlowData } from '@flowiseai/agentflow'
import '@flowiseai/agentflow/flowise.css'
export default function App() {
const ref = useRef<AgentFlowInstance>(null)
const initialFlow: FlowData = {
nodes: [
{
id: 'startAgentflow_0',
type: 'agentflowNode',
position: { x: 100, y: 100 },
data: {
id: 'startAgentflow_0',
name: 'startAgentflow',
label: 'Start',
color: '#7EE787',
hideInput: true,
outputAnchors: [{ id: 'startAgentflow_0-output-0', name: 'start', label: 'Start', type: 'start' }]
}
}
],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 }
}
return (
<div style={{ width: '100vw', height: '100vh' }}>
<Agentflow
ref={ref}
apiBaseUrl='http://localhost:3000'
token='your-api-key'
initialFlow={initialFlow}
onFlowChange={(flow) => console.log('Flow changed:', flow)}
onSave={(flow) => console.log('Flow saved:', flow)}
/>
</div>
)
}␍
```
## Props
| Prop | Type | Default | Description |
| -------------------- | ------------------------------------------ | -------------- | --------------------------------------------------------------- |
| `apiBaseUrl` | `string` | **(required)** | Flowise API server endpoint |
| `token` | `string` | — | Authentication token for API calls |
| `initialFlow` | `FlowData` | — | Initial flow data to render (uncontrolled — only used on mount) |
| `components` | `string[]` | — | Restrict which node types appear in the palette |
| `onFlowChange` | `(flow: FlowData) => void` | — | Called when the flow changes (node/edge add, remove, move) |
| `onSave` | `(flow: FlowData) => void` | — | Called when the user triggers a save |
| `onFlowGenerated` | `(flow: FlowData) => void` | — | Called when a flow is generated via AI |
| `isDarkMode` | `boolean` | `false` | Use dark mode theme |
| `readOnly` | `boolean` | `false` | Disable editing (nodes not draggable/connectable) |
| `showDefaultHeader` | `boolean` | `true` | Show built-in header (ignored if `renderHeader` provided) |
| `showDefaultPalette` | `boolean` | `true` | Show built-in node palette |
| `enableGenerator` | `boolean` | `true` | Show the AI flow generator button |
| `renderHeader` | `(props: HeaderRenderProps) => ReactNode` | — | Custom header renderer |
| `renderNodePalette` | `(props: PaletteRenderProps) => ReactNode` | — | Custom node palette renderer |
### Imperative Methods (via `ref`)
| Method | Return Type | Description |
| ------------------------ | ------------------------- | ------------------------------------- |
| `getFlow()` | `FlowData` | Get current flow data |
| `toJSON()` | `string` | Export flow as JSON string |
| `validate()` | `ValidationResult` | Validate the current flow |
| `fitView()` | `void` | Fit all nodes into view |
| `clear()` | `void` | Remove all nodes and edges |
| `addNode(nodeData)` | `void` | Add a node (`Partial<FlowNode>`) |
| `getReactFlowInstance()` | `ReactFlowInstance\|null` | Get the underlying ReactFlow instance |
### Design Note
`<Agentflow>` is an **uncontrolled component**. The `initialFlow` prop seeds the canvas state on mount, but the component owns its own state afterward. Use the `ref` for imperative access and `onFlowChange` to observe changes.
## Development
```bash
# Install dependencies
pnpm install
# Build the package
pnpm build
# Run tests
pnpm test
# Run examples
cd examples && pnpm install && pnpm dev
```
Visit the [examples](./examples) directory for more usage patterns. See [TESTS.md](./TESTS.md) for the full test plan and coverage status.
## Publishing
### Version Update
Bump the version in `package.json` before publishing. Use `npm version` to update the version and create a git tag:
```bash
# Prerelease (for testing)
npm version prerelease --preid=dev # 0.0.0-dev.1 → 0.0.0-dev.2
# Patch / Minor / Major (for stable releases)
npm version patch # 0.0.1
npm version minor # 0.1.0
npm version major # 1.0.0
```
### Verify Before Publishing
```bash
# Build and check the tarball contents
pnpm build
npm pack --dry-run
# Full publish dry-run (runs prepublishOnly + simulates upload)
npm publish --dry-run
```
### Publish
```bash
# Prerelease — tagged so `npm install @flowiseai/agentflow` won't pick it up
npm publish --tag dev
# Stable release — gets the `latest` tag
npm publish
```
> The `prepublishOnly` script automatically runs `clean` and `build` before every publish, so stale dist files are never uploaded.
## Documentation
- [ARCHITECTURE.md](./ARCHITECTURE.md) - Internal architecture and design patterns
- [TESTS.md](./TESTS.md) - Test plan, coverage tiers, and configuration
- [Examples](./examples/README.md) - Usage examples and demos
## Contributing
This package follows a feature-based architecture with clear separation of concerns. See [ARCHITECTURE.md](./ARCHITECTURE.md) for details on the project structure and development guidelines.
## License
Apache-2.0 — see the repository root [LICENSE.md](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md) for details.
---
Part of the [Flowise](https://github.com/FlowiseAI/Flowise) ecosystem
+1
View File
@@ -42,6 +42,7 @@
"@gomomento/sdk-core": "^1.51.1",
"@google-ai/generativelanguage": "^2.5.0",
"@google-cloud/storage": "^7.15.2",
"@azure/storage-blob": "^12.29.0",
"@google/generative-ai": "^0.24.0",
"@grpc/grpc-js": "^1.10.10",
"@huggingface/inference": "^4.13.2",
+1
View File
@@ -9,6 +9,7 @@ export * from './utils'
export * from './speechToText'
export * from './textToSpeech'
export * from './storageUtils'
export * from './storage'
export * from './handler'
export * from '../evaluation/EvaluationRunner'
export * from './followUpPrompts'
@@ -0,0 +1,331 @@
import { BlobServiceClient, ContainerClient, StorageSharedKeyCredential } from '@azure/storage-blob'
import multer from 'multer'
import { MulterAzureStorage } from 'multer-azure-blob-storage'
import { v4 as uuidv4 } from 'uuid'
import { BaseStorageProvider } from './BaseStorageProvider'
import { FileInfo, StorageResult, StorageSizeResult } from './IStorageProvider'
import { winstonAzureBlob } from 'winston-azure-blob'
/**
* Extends MulterAzureStorage to set file.path from file.blobName after upload.
* The server expects file.path (local/GCS) or file.key (S3) but multer-azure-blob-storage
* only sets file.blobName. This subclass bridges that gap.
*/
class MulterAzureStorageWithPath extends MulterAzureStorage {
_handleFile(req: any, file: any, cb: (error?: any, info?: any) => void): Promise<void> {
return super._handleFile(req, file, (err: any, info: any) => {
if (!err && info) {
info.path = info.blobName
}
cb(err, info)
})
}
}
export class AzureBlobStorageProvider extends BaseStorageProvider {
private containerClient: ContainerClient
private containerName: string
constructor() {
super()
const config = this.initAzureConfig()
this.containerClient = config.containerClient
this.containerName = config.containerName
}
private initAzureConfig(): {
containerClient: ContainerClient
containerName: string
} {
const connectionString = process.env.AZURE_BLOB_STORAGE_CONNECTION_STRING
const accountName = process.env.AZURE_BLOB_STORAGE_ACCOUNT_NAME
const accountKey = process.env.AZURE_BLOB_STORAGE_ACCOUNT_KEY
const containerName = process.env.AZURE_BLOB_STORAGE_CONTAINER_NAME
if (!containerName || containerName.trim() === '') {
throw new Error('AZURE_BLOB_STORAGE_CONTAINER_NAME env variable is required')
}
let blobServiceClient: BlobServiceClient
// Authenticate either using connection string or account name and key
if (connectionString && connectionString.trim() !== '') {
blobServiceClient = BlobServiceClient.fromConnectionString(connectionString)
} else if (accountName && accountName.trim() !== '' && accountKey && accountKey.trim() !== '') {
const sharedKeyCredential = new StorageSharedKeyCredential(accountName, accountKey)
blobServiceClient = new BlobServiceClient(`https://${accountName}.blob.core.windows.net`, sharedKeyCredential)
} else {
throw new Error(
'Azure Blob Storage configuration is missing. Provide AZURE_BLOB_STORAGE_CONNECTION_STRING or AZURE_BLOB_STORAGE_ACCOUNT_NAME + AZURE_BLOB_STORAGE_ACCOUNT_KEY'
)
}
const containerClient = blobServiceClient.getContainerClient(containerName)
return { containerClient, containerName }
}
getStorageType(): string {
return 'azure'
}
getConfig(): any {
return {
containerName: this.containerName
}
}
async addBase64FilesToStorage(fileBase64: string, chatflowid: string, fileNames: string[], orgId: string): Promise<StorageResult> {
// Validate chatflowid
this.validateChatflowId(chatflowid)
this.validatePathSecurity(chatflowid)
const splitDataURI = fileBase64.split(',')
const filename = splitDataURI.pop()?.split(':')[1] ?? ''
const bf = Buffer.from(splitDataURI.pop() || '', 'base64')
const mime = splitDataURI[0].split(':')[1].split(';')[0]
const sanitizedFilename = this.sanitizeFilename(filename)
const blobName = `${orgId}/${chatflowid}/${sanitizedFilename}`
const blockBlobClient = this.containerClient.getBlockBlobClient(blobName)
await blockBlobClient.upload(bf, bf.length, {
blobHTTPHeaders: { blobContentType: mime }
})
fileNames.push(sanitizedFilename)
const totalSize = await this.getStorageSize(orgId)
return { path: 'FILE-STORAGE::' + JSON.stringify(fileNames), totalSize: totalSize / 1024 / 1024 }
}
async addArrayFilesToStorage(
mime: string,
bf: Buffer,
fileName: string,
fileNames: string[],
...paths: string[]
): Promise<StorageResult> {
const sanitizedFilename = this.sanitizeFilename(fileName)
let blobName = paths.reduce((acc, cur) => acc + '/' + cur, '') + '/' + sanitizedFilename
if (blobName.startsWith('/')) {
blobName = blobName.substring(1)
}
const blockBlobClient = this.containerClient.getBlockBlobClient(blobName)
await blockBlobClient.upload(bf, bf.length, {
blobHTTPHeaders: { blobContentType: mime }
})
fileNames.push(sanitizedFilename)
const totalSize = await this.getStorageSize(paths[0])
return { path: 'FILE-STORAGE::' + JSON.stringify(fileNames), totalSize: totalSize / 1024 / 1024 }
}
async addSingleFileToStorage(mime: string, bf: Buffer, fileName: string, ...paths: string[]): Promise<StorageResult> {
const sanitizedFilename = this.sanitizeFilename(fileName)
let blobName = paths.reduce((acc, cur) => acc + '/' + cur, '') + '/' + sanitizedFilename
if (blobName.startsWith('/')) {
blobName = blobName.substring(1)
}
const blockBlobClient = this.containerClient.getBlockBlobClient(blobName)
await blockBlobClient.upload(bf, bf.length, {
blobHTTPHeaders: { blobContentType: mime }
})
const totalSize = await this.getStorageSize(paths[0])
return { path: 'FILE-STORAGE::' + sanitizedFilename, totalSize: totalSize / 1024 / 1024 }
}
async getFileFromUpload(filePath: string): Promise<Buffer> {
let blobName = filePath
// remove the first '/' if it exists
if (blobName.startsWith('/')) {
blobName = blobName.substring(1)
}
const blockBlobClient = this.containerClient.getBlockBlobClient(blobName)
const downloadResponse = await blockBlobClient.downloadToBuffer()
return downloadResponse
}
async getFileFromStorage(file: string, ...paths: string[]): Promise<Buffer> {
const sanitizedFilename = this.sanitizeFilename(file)
let blobName = paths.reduce((acc, cur) => acc + '/' + cur, '') + '/' + sanitizedFilename
if (blobName.startsWith('/')) {
blobName = blobName.substring(1)
}
const blockBlobClient = this.containerClient.getBlockBlobClient(blobName)
const buffer = await blockBlobClient.downloadToBuffer()
return buffer
}
async streamStorageFile(chatflowId: string, chatId: string, fileName: string, orgId: string): Promise<Buffer | undefined> {
// Validate chatflowId and chatId
this.validateChatflowId(chatflowId)
this.validatePathSecurity(chatflowId, chatId)
const sanitizedFilename = this.sanitizeFilename(fileName)
const blobName = `${orgId}/${chatflowId}/${chatId}/${sanitizedFilename}`
const blockBlobClient = this.containerClient.getBlockBlobClient(blobName)
const buffer = await blockBlobClient.downloadToBuffer()
return buffer
}
async getFilesListFromStorage(...paths: string[]): Promise<FileInfo[]> {
let prefix = paths.reduce((acc, cur) => acc + '/' + cur, '')
if (prefix.startsWith('/')) {
prefix = prefix.substring(1)
}
const filesList: FileInfo[] = []
for await (const blob of this.containerClient.listBlobsFlat({ prefix })) {
filesList.push({
name: blob.name.split('/').pop() || '',
path: blob.name,
size: blob.properties.contentLength || 0
})
}
return filesList
}
async removeFilesFromStorage(...paths: string[]): Promise<StorageSizeResult> {
let prefix = paths.reduce((acc, cur) => acc + '/' + cur, '')
if (prefix.startsWith('/')) {
prefix = prefix.substring(1)
}
// Delete all blobs with the prefix
for await (const blob of this.containerClient.listBlobsFlat({ prefix })) {
await this.containerClient.deleteBlob(blob.name)
}
const totalSize = await this.getStorageSize(paths[0])
return { totalSize: totalSize / 1024 / 1024 }
}
async removeSpecificFileFromUpload(filePath: string): Promise<void> {
let blobName = filePath
if (blobName.startsWith('/')) {
blobName = blobName.substring(1)
}
await this.containerClient.deleteBlob(blobName)
}
async removeSpecificFileFromStorage(...paths: string[]): Promise<StorageSizeResult> {
let blobName = paths.reduce((acc, cur) => acc + '/' + cur, '')
if (blobName.startsWith('/')) {
blobName = blobName.substring(1)
}
const blockBlobClient = this.containerClient.getBlockBlobClient(blobName)
if (await blockBlobClient.exists()) {
await blockBlobClient.delete()
}
const totalSize = await this.getStorageSize(paths[0])
return { totalSize: totalSize / 1024 / 1024 }
}
async removeFolderFromStorage(...paths: string[]): Promise<StorageSizeResult> {
let prefix = paths.reduce((acc, cur) => acc + '/' + cur, '')
if (prefix.startsWith('/')) {
prefix = prefix.substring(1)
}
// Delete all blobs with the prefix
for await (const blob of this.containerClient.listBlobsFlat({ prefix })) {
await this.containerClient.deleteBlob(blob.name)
}
const totalSize = await this.getStorageSize(paths[0])
return { totalSize: totalSize / 1024 / 1024 }
}
async getStorageSize(orgId: string): Promise<number> {
if (!orgId) return 0
let totalSize = 0
for await (const blob of this.containerClient.listBlobsFlat({ prefix: orgId })) {
totalSize += blob.properties.contentLength || 0
}
return totalSize
}
getMulterStorage(): multer.Multer {
const connectionString = process.env.AZURE_BLOB_STORAGE_CONNECTION_STRING
let storageConfig: any = {
containerName: this.containerName,
blobName: async (_req: any, file: any) => `uploads/${uuidv4()}/${file.originalname}`
}
// Use connection string if available, otherwise use account name/key
if (connectionString && connectionString.trim() !== '') {
storageConfig.connectionString = connectionString
} else {
storageConfig.accountName = process.env.AZURE_BLOB_STORAGE_ACCOUNT_NAME
storageConfig.accessKey = process.env.AZURE_BLOB_STORAGE_ACCOUNT_KEY
}
const azureStorage = new MulterAzureStorageWithPath(storageConfig)
return multer({ storage: azureStorage })
}
getLoggerTransports(logType: 'server' | 'error' | 'requests'): any[] {
const connectionString = process.env.AZURE_BLOB_STORAGE_CONNECTION_STRING
const accountName = process.env.AZURE_BLOB_STORAGE_ACCOUNT_NAME
const accountKey = process.env.AZURE_BLOB_STORAGE_ACCOUNT_KEY
let baseConfig: any = { containerName: this.containerName }
// Support both connection string and account name/key authentication
if (connectionString && connectionString.trim() !== '') {
baseConfig.account = { connectionString }
} else {
baseConfig.account = { name: accountName, key: accountKey }
}
if (logType === 'server') {
return [
winstonAzureBlob({
...baseConfig,
blobName: 'logs/server/server',
rotatePeriod: 'YYYY-MM-DD',
extension: '.log',
level: 'info'
})
]
} else if (logType === 'error') {
return [
winstonAzureBlob({
...baseConfig,
blobName: 'logs/error/server-error',
rotatePeriod: 'YYYY-MM-DD',
extension: '.log',
level: 'error'
})
]
} else if (logType === 'requests') {
return [
winstonAzureBlob({
...baseConfig,
blobName: 'logs/requests/server-requests',
rotatePeriod: 'YYYY-MM-DD',
extension: '.log.jsonl',
level: 'debug'
})
]
}
return []
}
}
@@ -0,0 +1,100 @@
import path from 'node:path'
import { IStorageProvider, FileInfo, StorageResult, StorageSizeResult } from './IStorageProvider'
import sanitize from 'sanitize-filename'
import { getUserHome } from '../utils'
import { isPathTraversal, isUnsafeFilePath, isValidUUID } from '../validator'
import fs from 'node:fs'
export abstract class BaseStorageProvider implements IStorageProvider {
protected storagePath: string
constructor() {
this.storagePath = this.getStoragePath()
}
abstract getStorageType(): string
abstract getConfig(): any
abstract addBase64FilesToStorage(fileBase64: string, chatflowid: string, fileNames: string[], orgId: string): Promise<StorageResult>
abstract addArrayFilesToStorage(
mime: string,
bf: Buffer,
fileName: string,
fileNames: string[],
...paths: string[]
): Promise<StorageResult>
abstract addSingleFileToStorage(mime: string, bf: Buffer, fileName: string, ...paths: string[]): Promise<StorageResult>
abstract getFileFromUpload(filePath: string): Promise<Buffer>
abstract getFileFromStorage(file: string, ...paths: string[]): Promise<Buffer>
abstract getFilesListFromStorage(...paths: string[]): Promise<FileInfo[]>
abstract streamStorageFile(
chatflowId: string,
chatId: string,
fileName: string,
orgId: string
): Promise<fs.ReadStream | Buffer | undefined>
abstract removeFilesFromStorage(...paths: string[]): Promise<StorageSizeResult>
abstract removeSpecificFileFromUpload(filePath: string): Promise<void>
abstract removeSpecificFileFromStorage(...paths: string[]): Promise<StorageSizeResult>
abstract removeFolderFromStorage(...paths: string[]): Promise<StorageSizeResult>
abstract getStorageSize(orgId: string): Promise<number>
abstract getMulterStorage(): any
abstract getLoggerTransports(logType: 'server' | 'error' | 'requests', config?: any): any[]
/**
* Shared utility for sanitizing filenames to prevent path traversal and other issues
*/
protected sanitizeFilename(filename: string): string {
if (!filename || isUnsafeFilePath(filename)) {
throw new Error('Invalid or unsafe fileName detected')
}
const sanitizedFilename = sanitize(filename)
// Remove leading dots to prevent hidden files or relative path jumps
const cleaned = sanitizedFilename.replace(/^\.+/, '')
if (!cleaned || cleaned.includes('/') || cleaned.includes('\\')) {
throw new Error('Invalid filename after sanitization')
}
return cleaned
}
/**
* Shared utility for getting the base storage path
*/
protected getStoragePath(): string {
const storagePath = process.env.BLOB_STORAGE_PATH
? path.join(process.env.BLOB_STORAGE_PATH)
: path.join(getUserHome(), '.flowise', 'storage')
if (!fs.existsSync(storagePath)) {
fs.mkdirSync(storagePath, { recursive: true })
}
return storagePath
}
/**
* Shared utility for validating chatflowId format (UUID)
*/
protected validateChatflowId(chatflowId: string): void {
if (!chatflowId || !isValidUUID(chatflowId)) {
throw new Error('Invalid chatflowId format - must be a valid UUID')
}
}
/**
* Shared utility for checking path traversal attempts
*/
protected validatePathSecurity(...paths: string[]): void {
for (const p of paths) {
if (p && isPathTraversal(p)) {
throw new Error('Invalid path characters detected')
}
}
}
/**
* Shared utility for building a storage path from components
*/
protected buildPath(...paths: string[]): string {
const sanitizedPaths = paths.filter((p) => p && typeof p === 'string').map((p) => this.sanitizeFilename(p))
return path.join(this.storagePath, ...sanitizedPaths)
}
}
@@ -0,0 +1,374 @@
import { Storage, Bucket } from '@google-cloud/storage'
import { LoggingWinston } from '@google-cloud/logging-winston'
import multer from 'multer'
import { v4 as uuidv4 } from 'uuid'
import { BaseStorageProvider } from './BaseStorageProvider'
import { FileInfo, StorageResult, StorageSizeResult } from './IStorageProvider'
import MulterGoogleCloudStorage from 'multer-cloud-storage'
export class GCSStorageProvider extends BaseStorageProvider {
private bucket: Bucket
private bucketName: string
private projectId: string | undefined
private keyFilename: string | undefined
constructor() {
super()
const config = this.initGCSConfig()
this.bucket = config.bucket
this.bucketName = config.bucketName
this.projectId = config.projectId
this.keyFilename = config.keyFilename
}
private initGCSConfig(): {
bucket: Bucket
bucketName: string
projectId: string | undefined
keyFilename: string | undefined
} {
const keyFilename = process.env.GOOGLE_CLOUD_STORAGE_CREDENTIAL
const projectId = process.env.GOOGLE_CLOUD_STORAGE_PROJ_ID
const bucketName = process.env.GOOGLE_CLOUD_STORAGE_BUCKET_NAME
if (!bucketName) {
throw new Error('GOOGLE_CLOUD_STORAGE_BUCKET_NAME env variable is required')
}
const storageConfig = {
...(keyFilename ? { keyFilename } : {}),
...(projectId ? { projectId } : {})
}
const storage = new Storage(storageConfig)
const bucket = storage.bucket(bucketName)
return { bucket, bucketName, projectId, keyFilename }
}
getStorageType(): string {
return 'gcs'
}
getConfig(): any {
return {
bucketName: this.bucketName,
projectId: this.projectId
}
}
private normalizePath(p: string): string {
return p.replace(/\\/g, '/')
}
async addBase64FilesToStorage(fileBase64: string, chatflowid: string, fileNames: string[], orgId: string): Promise<StorageResult> {
// Validate chatflowid
this.validateChatflowId(chatflowid)
this.validatePathSecurity(chatflowid)
const splitDataURI = fileBase64.split(',')
const filename = splitDataURI.pop()?.split(':')[1] ?? ''
const bf = Buffer.from(splitDataURI.pop() || '', 'base64')
const mime = splitDataURI[0].split(':')[1].split(';')[0]
const sanitizedFilename = this.sanitizeFilename(filename)
const normalizedChatflowid = this.normalizePath(chatflowid)
const normalizedFilename = this.normalizePath(sanitizedFilename)
const filePath = `${orgId}/${normalizedChatflowid}/${normalizedFilename}`
const file = this.bucket.file(filePath)
await new Promise<void>((resolve, reject) => {
file.createWriteStream({ contentType: mime, metadata: { contentEncoding: 'base64' } })
.on('error', (err) => reject(err))
.on('finish', () => resolve())
.end(bf)
})
fileNames.push(sanitizedFilename)
const totalSize = await this.getStorageSize(orgId)
return { path: 'FILE-STORAGE::' + JSON.stringify(fileNames), totalSize: totalSize / 1024 / 1024 }
}
async addArrayFilesToStorage(
mime: string,
bf: Buffer,
fileName: string,
fileNames: string[],
...paths: string[]
): Promise<StorageResult> {
const sanitizedFilename = this.sanitizeFilename(fileName)
const normalizedPaths = paths.map((p) => this.normalizePath(p))
const normalizedFilename = this.normalizePath(sanitizedFilename)
const filePath = [...normalizedPaths, normalizedFilename].join('/')
const file = this.bucket.file(filePath)
await new Promise<void>((resolve, reject) => {
file.createWriteStream()
.on('error', (err) => reject(err))
.on('finish', () => resolve())
.end(bf)
})
fileNames.push(sanitizedFilename)
const totalSize = await this.getStorageSize(paths[0])
return { path: 'FILE-STORAGE::' + JSON.stringify(fileNames), totalSize: totalSize / 1024 / 1024 }
}
async addSingleFileToStorage(mime: string, bf: Buffer, fileName: string, ...paths: string[]): Promise<StorageResult> {
const sanitizedFilename = this.sanitizeFilename(fileName)
const normalizedPaths = paths.map((p) => this.normalizePath(p))
const normalizedFilename = this.normalizePath(sanitizedFilename)
const filePath = [...normalizedPaths, normalizedFilename].join('/')
const file = this.bucket.file(filePath)
await new Promise<void>((resolve, reject) => {
file.createWriteStream({ contentType: mime, metadata: { contentEncoding: 'base64' } })
.on('error', (err) => reject(err))
.on('finish', () => resolve())
.end(bf)
})
const totalSize = await this.getStorageSize(paths[0])
return { path: 'FILE-STORAGE::' + sanitizedFilename, totalSize: totalSize / 1024 / 1024 }
}
async getFileFromUpload(filePath: string): Promise<Buffer> {
const file = this.bucket.file(filePath)
const [buffer] = await file.download()
return buffer
}
async getFileFromStorage(file: string, ...paths: string[]): Promise<Buffer> {
const sanitizedFilename = this.sanitizeFilename(file)
const normalizedPaths = paths.map((p) => this.normalizePath(p))
const normalizedFilename = this.normalizePath(sanitizedFilename)
const filePath = [...normalizedPaths, normalizedFilename].join('/')
try {
const gcsFile = this.bucket.file(filePath)
const [buffer] = await gcsFile.download()
return buffer
} catch (error) {
// Fallback: Check if file exists without the first path element (likely orgId)
if (normalizedPaths.length > 1) {
const fallbackPaths = normalizedPaths.slice(1)
const fallbackPath = [...fallbackPaths, normalizedFilename].join('/')
try {
const fallbackFile = this.bucket.file(fallbackPath)
const [buffer] = await fallbackFile.download()
// Move to correct location with orgId
const gcsFile = this.bucket.file(filePath)
await new Promise<void>((resolve, reject) => {
gcsFile
.createWriteStream()
.on('error', (err) => reject(err))
.on('finish', () => resolve())
.end(buffer)
})
// Delete the old file
await fallbackFile.delete()
// Check if the directory is empty and delete recursively if needed
if (fallbackPaths.length > 0) {
await this.cleanEmptyGCSFolders(fallbackPaths[0])
}
return buffer
} catch (fallbackError) {
throw error
}
} else {
throw error
}
}
}
async streamStorageFile(chatflowId: string, chatId: string, fileName: string, orgId: string): Promise<Buffer | undefined> {
// Validate chatflowId and chatId
this.validateChatflowId(chatflowId)
this.validatePathSecurity(chatflowId, chatId)
const sanitizedFilename = this.sanitizeFilename(fileName)
const normalizedChatflowId = this.normalizePath(chatflowId)
const normalizedChatId = this.normalizePath(chatId)
const normalizedFilename = this.normalizePath(sanitizedFilename)
const filePath = `${orgId}/${normalizedChatflowId}/${normalizedChatId}/${normalizedFilename}`
try {
const [buffer] = await this.bucket.file(filePath).download()
return buffer
} catch (error) {
// Fallback: Check if file exists without orgId
const fallbackPath = `${normalizedChatflowId}/${normalizedChatId}/${normalizedFilename}`
try {
const fallbackFile = this.bucket.file(fallbackPath)
const [buffer] = await fallbackFile.download()
// If found, copy to correct location with orgId
if (buffer) {
const file = this.bucket.file(filePath)
await new Promise<void>((resolve, reject) => {
file.createWriteStream()
.on('error', (err) => reject(err))
.on('finish', () => resolve())
.end(buffer)
})
// Delete the old file
await fallbackFile.delete()
// Check if the directory is empty and delete recursively if needed
await this.cleanEmptyGCSFolders(normalizedChatflowId)
return buffer
}
} catch (fallbackError) {
throw new Error(`File ${fileName} not found`)
}
}
}
async getFilesListFromStorage(...paths: string[]): Promise<FileInfo[]> {
const normalizedPaths = paths.map((p) => this.normalizePath(p))
const prefix = normalizedPaths.join('/')
const [files] = await this.bucket.getFiles({ prefix })
return files.map((file) => ({
name: file.name.split('/').pop() || '',
path: file.name,
size: typeof file.metadata.size === 'string' ? parseInt(file.metadata.size, 10) || 0 : file.metadata.size || 0
}))
}
async removeFilesFromStorage(...paths: string[]): Promise<StorageSizeResult> {
const normalizedPath = paths.map((p) => this.normalizePath(p)).join('/')
await this.bucket.deleteFiles({ prefix: `${normalizedPath}/` })
const totalSize = await this.getStorageSize(paths[0])
return { totalSize: totalSize / 1024 / 1024 }
}
async removeSpecificFileFromUpload(filePath: string): Promise<void> {
await this.bucket.file(filePath).delete()
}
async removeSpecificFileFromStorage(...paths: string[]): Promise<StorageSizeResult> {
const fileName = paths.pop()
if (fileName) {
const sanitizedFilename = this.sanitizeFilename(fileName)
paths.push(sanitizedFilename)
}
const normalizedPath = paths.map((p) => this.normalizePath(p)).join('/')
await this.bucket.file(normalizedPath).delete()
const totalSize = await this.getStorageSize(paths[0])
return { totalSize: totalSize / 1024 / 1024 }
}
async removeFolderFromStorage(...paths: string[]): Promise<StorageSizeResult> {
const normalizedPath = paths.map((p) => this.normalizePath(p)).join('/')
await this.bucket.deleteFiles({ prefix: `${normalizedPath}/` })
const totalSize = await this.getStorageSize(paths[0])
return { totalSize: totalSize / 1024 / 1024 }
}
private async cleanEmptyGCSFolders(prefix: string): Promise<void> {
try {
if (!prefix) return
const [files] = await this.bucket.getFiles({
prefix: prefix + '/',
delimiter: '/'
})
if (files.length === 0) {
try {
await this.bucket.file(prefix + '/').delete()
} catch (err) {
// Folder marker might not exist, ignore
}
const parentPrefix = prefix.substring(0, prefix.lastIndexOf('/'))
if (parentPrefix) {
await this.cleanEmptyGCSFolders(parentPrefix)
}
}
} catch (error) {
console.error('Error cleaning empty GCS folders:', error)
}
}
async getStorageSize(orgId: string): Promise<number> {
if (!orgId) return 0
const [files] = await this.bucket.getFiles({ prefix: orgId })
let totalSize = 0
for (const file of files) {
const size = file.metadata.size
if (typeof size === 'string') {
totalSize += parseInt(size, 10) || 0
} else if (typeof size === 'number') {
totalSize += size
}
}
return totalSize
}
getMulterStorage(): multer.Multer {
return multer({
storage: new MulterGoogleCloudStorage({
projectId: this.projectId,
bucket: this.bucketName,
keyFilename: this.keyFilename,
uniformBucketLevelAccess: Boolean(process.env.GOOGLE_CLOUD_UNIFORM_BUCKET_ACCESS) ?? true,
destination: `uploads/${uuidv4()}`
})
})
}
getLoggerTransports(logType: 'server' | 'error' | 'requests'): any[] {
const gcsConfig = {
projectId: this.projectId,
keyFilename: this.keyFilename,
defaultCallback: (err: any) => {
if (err) {
console.error('Error logging to GCS: ' + err)
}
}
}
if (logType === 'server') {
return [
new LoggingWinston({
...gcsConfig,
logName: 'server'
})
]
} else if (logType === 'error') {
return [
new LoggingWinston({
...gcsConfig,
logName: 'error'
})
]
} else if (logType === 'requests') {
return [
new LoggingWinston({
...gcsConfig,
logName: 'requests'
})
]
}
return []
}
}
@@ -0,0 +1,98 @@
import fs from 'node:fs'
export interface FileInfo {
name: string
path: string
size: number
}
export interface StorageResult {
path: string
totalSize: number
}
export interface StorageSizeResult {
totalSize: number
}
export interface IStorageProvider {
/**
* Get the storage type (e.g., 'local', 's3', 'gcs', 'azure')
*/
getStorageType(): string
/**
* Get the provider configuration
*/
getConfig(): any
/**
* Add files provided as base64 strings to storage
*/
addBase64FilesToStorage(fileBase64: string, chatflowid: string, fileNames: string[], orgId: string): Promise<StorageResult>
/**
* Add file buffer to storage as part of an array
*/
addArrayFilesToStorage(mime: string, bf: Buffer, fileName: string, fileNames: string[], ...paths: string[]): Promise<StorageResult>
/**
* Add a single file buffer to storage
*/
addSingleFileToStorage(mime: string, bf: Buffer, fileName: string, ...paths: string[]): Promise<StorageResult>
/**
* Retrieve a file from the upload directory
*/
getFileFromUpload(filePath: string): Promise<Buffer>
/**
* Retrieve a file from generic storage
*/
getFileFromStorage(file: string, ...paths: string[]): Promise<Buffer>
/**
* List files in a storage path
*/
getFilesListFromStorage(...paths: string[]): Promise<FileInfo[]>
/**
* Stream a file from storage
*/
streamStorageFile(chatflowId: string, chatId: string, fileName: string, orgId: string): Promise<fs.ReadStream | Buffer | undefined>
/**
* Remove multiple files from storage
*/
removeFilesFromStorage(...paths: string[]): Promise<StorageSizeResult>
/**
* Remove a specific file from the upload directory
*/
removeSpecificFileFromUpload(filePath: string): Promise<void>
/**
* Remove a specific file from storage
*/
removeSpecificFileFromStorage(...paths: string[]): Promise<StorageSizeResult>
/**
* Remove a complete folder and its contents from storage
*/
removeFolderFromStorage(...paths: string[]): Promise<StorageSizeResult>
/**
* Calculate the total storage size for an organization
*/
getStorageSize(orgId: string): Promise<number>
/**
* Get the Multer storage engine for this provider
*/
getMulterStorage(): any
/**
* Get the Winston logger transports for this provider
*/
getLoggerTransports(logType: 'server' | 'error' | 'requests', config?: any): any[]
}
@@ -0,0 +1,381 @@
import path from 'node:path'
import fs from 'fs'
import multer from 'multer'
import DailyRotateFile from 'winston-daily-rotate-file'
import { transports } from 'winston'
import { BaseStorageProvider } from './BaseStorageProvider'
import { FileInfo, StorageResult, StorageSizeResult } from './IStorageProvider'
export class LocalStorageProvider extends BaseStorageProvider {
constructor() {
super()
}
getStorageType(): string {
return 'local'
}
getConfig(): any {
return {
storagePath: this.storagePath
}
}
async addBase64FilesToStorage(fileBase64: string, chatflowid: string, fileNames: string[], orgId: string): Promise<StorageResult> {
// Validate chatflowid
this.validateChatflowId(chatflowid)
this.validatePathSecurity(chatflowid)
const dir = this.buildPath(orgId, chatflowid)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
const splitDataURI = fileBase64.split(',')
const filename = splitDataURI.pop()?.split(':')[1] ?? ''
const bf = Buffer.from(splitDataURI.pop() || '', 'base64')
const sanitizedFilename = this.sanitizeFilename(filename)
const filePath = path.join(dir, sanitizedFilename)
fs.writeFileSync(filePath, bf)
fileNames.push(sanitizedFilename)
const totalSize = await this.getStorageSize(orgId)
return { path: 'FILE-STORAGE::' + JSON.stringify(fileNames), totalSize: totalSize / 1024 / 1024 }
}
async addArrayFilesToStorage(
mime: string,
bf: Buffer,
fileName: string,
fileNames: string[],
...paths: string[]
): Promise<StorageResult> {
const dir = this.buildPath(...paths)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
const sanitizedFilename = this.sanitizeFilename(fileName)
const filePath = path.join(dir, sanitizedFilename)
fs.writeFileSync(filePath, bf)
fileNames.push(sanitizedFilename)
const totalSize = await this.getStorageSize(paths[0])
return { path: 'FILE-STORAGE::' + JSON.stringify(fileNames), totalSize: totalSize / 1024 / 1024 }
}
async addSingleFileToStorage(mime: string, bf: Buffer, fileName: string, ...paths: string[]): Promise<StorageResult> {
const dir = this.buildPath(...paths)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
const sanitizedFilename = this.sanitizeFilename(fileName)
const filePath = path.join(dir, sanitizedFilename)
fs.writeFileSync(filePath, bf)
const totalSize = await this.getStorageSize(paths[0])
return { path: 'FILE-STORAGE::' + sanitizedFilename, totalSize: totalSize / 1024 / 1024 }
}
async getFileFromUpload(filePath: string): Promise<Buffer> {
return fs.readFileSync(filePath)
}
async getFileFromStorage(file: string, ...paths: string[]): Promise<Buffer> {
const sanitizedFilename = this.sanitizeFilename(file)
const fileInStorage = this.buildPath(...paths.map((p) => this.sanitizeFilename(p)), sanitizedFilename)
try {
return fs.readFileSync(fileInStorage)
} catch (error) {
// Fallback: Check if file exists without the first path element (likely orgId)
if (paths.length > 1) {
const fallbackPaths = paths.slice(1)
const fallbackPath = this.buildPath(...fallbackPaths.map((p) => this.sanitizeFilename(p)), sanitizedFilename)
if (fs.existsSync(fallbackPath)) {
// Create directory if it doesn't exist
const targetPath = fileInStorage
const dir = path.dirname(targetPath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
// Copy file to correct location with orgId
fs.copyFileSync(fallbackPath, targetPath)
// Delete the old file
fs.unlinkSync(fallbackPath)
// Clean up empty directories recursively
if (fallbackPaths.length > 0) {
this.cleanEmptyLocalFolders(this.buildPath(...fallbackPaths.map((p) => this.sanitizeFilename(p)).slice(0, -1)))
}
return fs.readFileSync(targetPath)
}
}
throw error
}
}
async streamStorageFile(
chatflowId: string,
chatId: string,
fileName: string,
orgId: string
): Promise<fs.ReadStream | Buffer | undefined> {
// Validate chatflowId and chatId
this.validateChatflowId(chatflowId)
this.validatePathSecurity(chatflowId, chatId)
const sanitizedFilename = this.sanitizeFilename(fileName)
const filePath = this.buildPath(orgId, chatflowId, chatId, sanitizedFilename)
//raise error if file path is not absolute
if (!path.isAbsolute(filePath)) throw new Error(`Invalid file path`)
//raise error if file path contains '..'
if (filePath.includes('..')) throw new Error(`Invalid file path`)
//only return from the storage folder
if (!filePath.startsWith(this.storagePath)) throw new Error(`Invalid file path`)
if (fs.existsSync(filePath)) {
return fs.createReadStream(filePath)
} else {
// Fallback: Check if file exists without orgId
const fallbackPath = this.buildPath(chatflowId, chatId, sanitizedFilename)
if (fs.existsSync(fallbackPath)) {
// Create directory if it doesn't exist
const dir = path.dirname(filePath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
// Copy file to correct location with orgId
fs.copyFileSync(fallbackPath, filePath)
// Delete the old file
fs.unlinkSync(fallbackPath)
// Clean up empty directories recursively
this.cleanEmptyLocalFolders(path.dirname(fallbackPath))
return fs.createReadStream(filePath)
} else {
throw new Error(`File ${fileName} not found`)
}
}
}
async getFilesListFromStorage(...paths: string[]): Promise<FileInfo[]> {
const directory = this.buildPath(...paths)
return this.getFilePaths(directory)
}
private getFilePaths(dir: string): FileInfo[] {
let results: FileInfo[] = []
const readDirectory = (directory: string) => {
try {
if (!fs.existsSync(directory)) {
console.warn(`Directory does not exist: ${directory}`)
return
}
const list = fs.readdirSync(directory)
list.forEach((file) => {
const filePath = path.join(directory, file)
try {
const stat = fs.statSync(filePath)
if (stat && stat.isDirectory()) {
readDirectory(filePath)
} else {
results.push({ name: file, path: filePath, size: stat.size })
}
} catch (error) {
console.error(`Error processing file ${filePath}:`, error)
}
})
} catch (error) {
console.error(`Error reading directory ${directory}:`, error)
}
}
readDirectory(dir)
return results
}
private cleanEmptyLocalFolders(dirPath: string) {
try {
// Stop if we reach the storage root
if (dirPath === this.storagePath) return
// Check if directory exists
if (!fs.existsSync(dirPath)) return
// Read directory contents
const files = fs.readdirSync(dirPath)
// If directory is empty, delete it and check parent
if (files.length === 0) {
fs.rmdirSync(dirPath)
// Recursively check parent directory
this.cleanEmptyLocalFolders(path.dirname(dirPath))
}
} catch (error) {
// Ignore errors during cleanup
console.error('Error cleaning empty folders:', error)
}
}
async removeFilesFromStorage(...paths: string[]): Promise<StorageSizeResult> {
const directory = this.buildPath(...paths.map((p) => this.sanitizeFilename(p)))
await this.deleteLocalFolderRecursive(directory)
const totalSize = await this.getStorageSize(paths[0])
return { totalSize: totalSize / 1024 / 1024 }
}
async removeSpecificFileFromUpload(filePath: string): Promise<void> {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath)
}
}
async removeSpecificFileFromStorage(...paths: string[]): Promise<StorageSizeResult> {
const fileName = paths.pop()
if (fileName) {
const sanitizedFilename = this.sanitizeFilename(fileName)
paths.push(sanitizedFilename)
}
const file = this.buildPath(...paths.map((p) => this.sanitizeFilename(p)))
// check if file exists, if not skip delete
const stat = fs.statSync(file, { throwIfNoEntry: false })
if (stat && stat.isFile()) {
fs.unlinkSync(file)
}
const totalSize = await this.getStorageSize(paths[0])
return { totalSize: totalSize / 1024 / 1024 }
}
async removeFolderFromStorage(...paths: string[]): Promise<StorageSizeResult> {
const directory = this.buildPath(...paths.map((p) => this.sanitizeFilename(p)))
await this.deleteLocalFolderRecursive(directory, true)
const totalSize = await this.getStorageSize(paths[0])
return { totalSize: totalSize / 1024 / 1024 }
}
private async deleteLocalFolderRecursive(directory: string, deleteParentChatflowFolder?: boolean): Promise<void> {
try {
// Check if the path exists
await fs.promises.access(directory)
if (deleteParentChatflowFolder) {
await fs.promises.rm(directory, { recursive: true })
return
}
// Get stats of the path to determine if it's a file or directory
const stats = await fs.promises.stat(directory)
if (stats.isDirectory()) {
// Read all directory contents
const files = await fs.promises.readdir(directory)
// Recursively delete all contents
for (const file of files) {
const currentPath = path.join(directory, file)
await this.deleteLocalFolderRecursive(currentPath)
}
// Delete the directory itself after emptying it
await fs.promises.rm(directory, { recursive: true })
} else {
// If it's a file, delete it directly
await fs.promises.unlink(directory)
}
} catch (error) {
// Error handling - ignore if file/directory doesn't exist
}
}
async getStorageSize(orgId: string): Promise<number> {
if (!orgId) return 0
const directory = this.buildPath(orgId)
return this.dirSize(directory)
}
private async dirSize(directoryPath: string): Promise<number> {
let totalSize = 0
const calculateSize = async (itemPath: string) => {
try {
const stats = await fs.promises.stat(itemPath)
if (stats.isFile()) {
totalSize += stats.size
} else if (stats.isDirectory()) {
const files = await fs.promises.readdir(itemPath)
for (const file of files) {
await calculateSize(path.join(itemPath, file))
}
}
} catch (error) {
// Ignore missing files/dirs during calculation
}
}
await calculateSize(directoryPath)
return totalSize
}
getMulterStorage(): multer.Multer {
const uploadPath = this.getUploadPath()
if (!fs.existsSync(uploadPath)) {
fs.mkdirSync(uploadPath, { recursive: true })
}
return multer({ dest: uploadPath })
}
private getUploadPath(): string {
return process.env.BLOB_STORAGE_PATH
? path.join(process.env.BLOB_STORAGE_PATH, 'uploads')
: path.join(this.getUserHome(), '.flowise', 'uploads')
}
private getUserHome(): string {
return process.env.HOME || process.env.USERPROFILE || process.env.HOMEPATH || ''
}
getLoggerTransports(logType: 'server' | 'error' | 'requests', config?: any): any[] {
const logDir = config?.logging?.dir || path.join(this.getUserHome(), '.flowise', 'logs')
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true })
}
if (logType === 'server') {
return [
new DailyRotateFile({
filename: path.join(logDir, config?.logging?.server?.filename ?? 'server-%DATE%.log'),
datePattern: 'YYYY-MM-DD-HH',
maxSize: '20m',
level: config?.logging?.server?.level ?? 'info'
})
]
} else if (logType === 'requests') {
return [
new transports.File({
filename: path.join(logDir, config?.logging?.express?.filename ?? 'server-requests.log.jsonl'),
level: config?.logging?.express?.level ?? 'debug'
})
]
}
// For 'error' type, return empty array (handled by exceptionHandlers in logger.ts)
return []
}
}
@@ -0,0 +1,538 @@
import {
DeleteObjectsCommand,
GetObjectCommand,
ListObjectsCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
S3ClientConfig
} from '@aws-sdk/client-s3'
import { Readable } from 'node:stream'
import multer from 'multer'
import multerS3 from 'multer-s3'
import { transports } from 'winston'
import { v4 as uuidv4 } from 'uuid'
import { BaseStorageProvider } from './BaseStorageProvider'
import { FileInfo, StorageResult, StorageSizeResult } from './IStorageProvider'
const { S3StreamLogger } = require('s3-streamlogger')
export class S3StorageProvider extends BaseStorageProvider {
private s3Client: S3Client
private bucket: string
private s3Config: S3ClientConfig
constructor() {
super()
const config = this.initS3Config()
this.s3Client = config.s3Client
this.bucket = config.bucket
this.s3Config = config.s3Config
}
private initS3Config(): { s3Client: S3Client; bucket: string; s3Config: S3ClientConfig } {
const accessKeyId = process.env.S3_STORAGE_ACCESS_KEY_ID
const secretAccessKey = process.env.S3_STORAGE_SECRET_ACCESS_KEY
const region = process.env.S3_STORAGE_REGION
const bucket = process.env.S3_STORAGE_BUCKET_NAME
const customURL = process.env.S3_ENDPOINT_URL
const forcePathStyle = process.env.S3_FORCE_PATH_STYLE === 'true'
if (!region || region.trim() === '' || !bucket || bucket.trim() === '') {
throw new Error('S3 storage configuration is missing')
}
const s3Config: S3ClientConfig = {
region: region,
forcePathStyle: forcePathStyle
}
// Only include endpoint if customURL is not empty
if (customURL && customURL.trim() !== '') {
s3Config.endpoint = customURL
}
if (accessKeyId && accessKeyId.trim() !== '' && secretAccessKey && secretAccessKey.trim() !== '') {
s3Config.credentials = {
accessKeyId: accessKeyId,
secretAccessKey: secretAccessKey
}
}
const s3Client = new S3Client(s3Config)
return { s3Client, bucket, s3Config }
}
getStorageType(): string {
return 's3'
}
getConfig(): any {
return {
bucket: this.bucket,
region: process.env.S3_STORAGE_REGION,
endpoint: process.env.S3_ENDPOINT_URL
}
}
async addBase64FilesToStorage(fileBase64: string, chatflowid: string, fileNames: string[], orgId: string): Promise<StorageResult> {
// Validate chatflowid
this.validateChatflowId(chatflowid)
this.validatePathSecurity(chatflowid)
const splitDataURI = fileBase64.split(',')
const filename = splitDataURI.pop()?.split(':')[1] ?? ''
const bf = Buffer.from(splitDataURI.pop() || '', 'base64')
const mime = splitDataURI[0].split(':')[1].split(';')[0]
const sanitizedFilename = this.sanitizeFilename(filename)
const Key = orgId + '/' + chatflowid + '/' + sanitizedFilename
const putObjCmd = new PutObjectCommand({
Bucket: this.bucket,
Key,
ContentEncoding: 'base64',
ContentType: mime,
Body: bf
})
await this.s3Client.send(putObjCmd)
fileNames.push(sanitizedFilename)
const totalSize = await this.getStorageSize(orgId)
return { path: 'FILE-STORAGE::' + JSON.stringify(fileNames), totalSize: totalSize / 1024 / 1024 }
}
async addArrayFilesToStorage(
mime: string,
bf: Buffer,
fileName: string,
fileNames: string[],
...paths: string[]
): Promise<StorageResult> {
const sanitizedFilename = this.sanitizeFilename(fileName)
let Key = paths.reduce((acc, cur) => acc + '/' + cur, '') + '/' + sanitizedFilename
if (Key.startsWith('/')) {
Key = Key.substring(1)
}
const putObjCmd = new PutObjectCommand({
Bucket: this.bucket,
Key,
ContentEncoding: 'base64',
ContentType: mime,
Body: bf
})
await this.s3Client.send(putObjCmd)
fileNames.push(sanitizedFilename)
const totalSize = await this.getStorageSize(paths[0])
return { path: 'FILE-STORAGE::' + JSON.stringify(fileNames), totalSize: totalSize / 1024 / 1024 }
}
async addSingleFileToStorage(mime: string, bf: Buffer, fileName: string, ...paths: string[]): Promise<StorageResult> {
const sanitizedFilename = this.sanitizeFilename(fileName)
let Key = paths.reduce((acc, cur) => acc + '/' + cur, '') + '/' + sanitizedFilename
if (Key.startsWith('/')) {
Key = Key.substring(1)
}
const putObjCmd = new PutObjectCommand({
Bucket: this.bucket,
Key,
ContentEncoding: 'base64',
ContentType: mime,
Body: bf
})
await this.s3Client.send(putObjCmd)
const totalSize = await this.getStorageSize(paths[0])
return { path: 'FILE-STORAGE::' + sanitizedFilename, totalSize: totalSize / 1024 / 1024 }
}
async getFileFromUpload(filePath: string): Promise<Buffer> {
// For S3, the filePath is the S3 key
let Key = filePath
if (Key.startsWith('/')) {
Key = Key.substring(1)
}
const getParams = {
Bucket: this.bucket,
Key
}
const response = await this.s3Client.send(new GetObjectCommand(getParams))
const body = response.Body
if (body instanceof Readable) {
const streamToString = await body.transformToString('base64')
if (streamToString) {
return Buffer.from(streamToString, 'base64')
}
}
const byteArray = await response.Body!.transformToByteArray()
return Buffer.from(byteArray)
}
async getFileFromStorage(file: string, ...paths: string[]): Promise<Buffer> {
const sanitizedFilename = this.sanitizeFilename(file)
let Key = paths.reduce((acc, cur) => acc + '/' + cur, '') + '/' + sanitizedFilename
if (Key.startsWith('/')) {
Key = Key.substring(1)
}
try {
const getParams = {
Bucket: this.bucket,
Key
}
const response = await this.s3Client.send(new GetObjectCommand(getParams))
const body = response.Body
if (body instanceof Readable) {
const streamToString = await body.transformToString('base64')
if (streamToString) {
return Buffer.from(streamToString, 'base64')
}
}
const byteArray = await response.Body!.transformToByteArray()
return Buffer.from(byteArray)
} catch (error) {
// Fallback: Check if file exists without the first path element (likely orgId)
if (paths.length > 1) {
const fallbackPaths = paths.slice(1)
let fallbackKey = fallbackPaths.reduce((acc, cur) => acc + '/' + cur, '') + '/' + sanitizedFilename
if (fallbackKey.startsWith('/')) {
fallbackKey = fallbackKey.substring(1)
}
try {
const fallbackParams = {
Bucket: this.bucket,
Key: fallbackKey
}
const fallbackResponse = await this.s3Client.send(new GetObjectCommand(fallbackParams))
const fallbackBody = fallbackResponse.Body
// Get the file content
let fileContent: Buffer
if (fallbackBody instanceof Readable) {
const streamToString = await fallbackBody.transformToString('base64')
if (streamToString) {
fileContent = Buffer.from(streamToString, 'base64')
} else {
const byteArray = await fallbackBody.transformToByteArray()
fileContent = Buffer.from(byteArray)
}
} else {
const byteArray = await fallbackBody!.transformToByteArray()
fileContent = Buffer.from(byteArray)
}
// Move to correct location with orgId
const putObjCmd = new PutObjectCommand({
Bucket: this.bucket,
Key,
Body: fileContent
})
await this.s3Client.send(putObjCmd)
// Delete the old file
await this.s3Client.send(
new DeleteObjectsCommand({
Bucket: this.bucket,
Delete: {
Objects: [{ Key: fallbackKey }],
Quiet: false
}
})
)
// Check if the directory is empty and delete recursively if needed
if (fallbackPaths.length > 0) {
await this.cleanEmptyS3Folders(fallbackPaths[0])
}
return fileContent
} catch (fallbackError) {
throw error
}
} else {
throw error
}
}
}
async streamStorageFile(chatflowId: string, chatId: string, fileName: string, orgId: string): Promise<Buffer | undefined> {
// Validate chatflowId and chatId
this.validateChatflowId(chatflowId)
this.validatePathSecurity(chatflowId, chatId)
const sanitizedFilename = this.sanitizeFilename(fileName)
const Key = orgId + '/' + chatflowId + '/' + chatId + '/' + sanitizedFilename
const getParams = {
Bucket: this.bucket,
Key
}
try {
const response = await this.s3Client.send(new GetObjectCommand(getParams))
const body = response.Body
if (body instanceof Readable) {
const blob = await body.transformToByteArray()
return Buffer.from(blob)
}
} catch (error) {
// Fallback: Check if file exists without orgId
const fallbackKey = chatflowId + '/' + chatId + '/' + sanitizedFilename
try {
const fallbackParams = {
Bucket: this.bucket,
Key: fallbackKey
}
const fallbackResponse = await this.s3Client.send(new GetObjectCommand(fallbackParams))
const fallbackBody = fallbackResponse.Body
// If found, copy to correct location with orgId
if (fallbackBody) {
let fileContent: Buffer
if (fallbackBody instanceof Readable) {
const blob = await fallbackBody.transformToByteArray()
fileContent = Buffer.from(blob)
} else {
const byteArray = await fallbackBody.transformToByteArray()
fileContent = Buffer.from(byteArray)
}
// Move to correct location with orgId
const putObjCmd = new PutObjectCommand({
Bucket: this.bucket,
Key,
Body: fileContent
})
await this.s3Client.send(putObjCmd)
// Delete the old file
await this.s3Client.send(
new DeleteObjectsCommand({
Bucket: this.bucket,
Delete: {
Objects: [{ Key: fallbackKey }],
Quiet: false
}
})
)
// Check if the directory is empty and delete recursively if needed
await this.cleanEmptyS3Folders(chatflowId)
return fileContent
}
} catch (fallbackError) {
throw new Error(`File ${fileName} not found`)
}
}
}
async getFilesListFromStorage(...paths: string[]): Promise<FileInfo[]> {
let Key = paths.reduce((acc, cur) => acc + '/' + cur, '')
if (Key.startsWith('/')) {
Key = Key.substring(1)
}
const listCommand = new ListObjectsV2Command({
Bucket: this.bucket,
Prefix: Key
})
const list = await this.s3Client.send(listCommand)
if (list.Contents && list.Contents.length > 0) {
return list.Contents.map((item) => ({
name: item.Key?.split('/').pop() || '',
path: item.Key ?? '',
size: item.Size || 0
}))
} else {
return []
}
}
async removeFilesFromStorage(...paths: string[]): Promise<StorageSizeResult> {
let Key = paths.reduce((acc, cur) => acc + '/' + cur, '')
if (Key.startsWith('/')) {
Key = Key.substring(1)
}
await this.deleteS3Folder(Key)
const totalSize = await this.getStorageSize(paths[0])
return { totalSize: totalSize / 1024 / 1024 }
}
async removeSpecificFileFromUpload(filePath: string): Promise<void> {
let Key = filePath
if (Key.startsWith('/')) {
Key = Key.substring(1)
}
await this.deleteS3Folder(Key)
}
async removeSpecificFileFromStorage(...paths: string[]): Promise<StorageSizeResult> {
let Key = paths.reduce((acc, cur) => acc + '/' + cur, '')
if (Key.startsWith('/')) {
Key = Key.substring(1)
}
await this.deleteS3Folder(Key)
const totalSize = await this.getStorageSize(paths[0])
return { totalSize: totalSize / 1024 / 1024 }
}
async removeFolderFromStorage(...paths: string[]): Promise<StorageSizeResult> {
let Key = paths.reduce((acc, cur) => acc + '/' + cur, '')
if (Key.startsWith('/')) {
Key = Key.substring(1)
}
await this.deleteS3Folder(Key)
const totalSize = await this.getStorageSize(paths[0])
return { totalSize: totalSize / 1024 / 1024 }
}
private async deleteS3Folder(location: string): Promise<string> {
let count = 0
const recursiveS3Delete = async (token?: string): Promise<string> => {
const listCommand = new ListObjectsV2Command({
Bucket: this.bucket,
Prefix: location,
ContinuationToken: token
})
const list = await this.s3Client.send(listCommand)
if (list.KeyCount) {
const deleteCommand = new DeleteObjectsCommand({
Bucket: this.bucket,
Delete: {
Objects: list.Contents?.map((item) => ({ Key: item.Key })),
Quiet: false
}
})
const deleted = await this.s3Client.send(deleteCommand)
// @ts-ignore
count += deleted.Deleted?.length || 0
if (deleted.Errors) {
deleted.Errors.map((error: any) => console.error(`${error.Key} could not be deleted - ${error.Code}`))
}
}
if (list.NextContinuationToken) {
return recursiveS3Delete(list.NextContinuationToken)
}
return `${count} files deleted from S3`
}
return recursiveS3Delete()
}
private async cleanEmptyS3Folders(prefix: string): Promise<void> {
try {
if (!prefix) return
const listCmd = new ListObjectsV2Command({
Bucket: this.bucket,
Prefix: prefix + '/',
Delimiter: '/'
})
const response = await this.s3Client.send(listCmd)
if (
(response.Contents?.length === 0 || !response.Contents) &&
(response.CommonPrefixes?.length === 0 || !response.CommonPrefixes)
) {
await this.s3Client.send(
new DeleteObjectsCommand({
Bucket: this.bucket,
Delete: {
Objects: [{ Key: prefix + '/' }],
Quiet: true
}
})
)
const parentPrefix = prefix.substring(0, prefix.lastIndexOf('/'))
if (parentPrefix) {
await this.cleanEmptyS3Folders(parentPrefix)
}
}
} catch (error) {
console.error('Error cleaning empty S3 folders:', error)
}
}
async getStorageSize(orgId: string): Promise<number> {
if (!orgId) return 0
const getCmd = new ListObjectsCommand({
Bucket: this.bucket,
Prefix: orgId
})
const headObj = await this.s3Client.send(getCmd)
let totalSize = 0
for (const obj of headObj.Contents || []) {
totalSize += obj.Size || 0
}
return totalSize
}
getMulterStorage(): multer.Multer {
return multer({
storage: multerS3({
s3: this.s3Client,
bucket: this.bucket,
metadata: function (req: any, file: any, cb: any) {
cb(null, { fieldName: file.fieldname, originalName: file.originalname })
},
key: function (req: any, file: any, cb: any) {
cb(null, `${uuidv4()}`)
}
})
})
}
getLoggerTransports(logType: 'server' | 'error' | 'requests'): any[] {
if (logType === 'server') {
const s3ServerStream = new S3StreamLogger({
bucket: this.bucket,
folder: 'logs/server',
name_format: `server-%Y-%m-%d-%H-%M-%S-%L.log`,
config: this.s3Config
})
return [new transports.Stream({ stream: s3ServerStream })]
} else if (logType === 'error') {
const s3ErrorStream = new S3StreamLogger({
bucket: this.bucket,
folder: 'logs/error',
name_format: `server-error-%Y-%m-%d-%H-%M-%S-%L.log`,
config: this.s3Config
})
return [new transports.Stream({ stream: s3ErrorStream })]
} else if (logType === 'requests') {
const s3ServerReqStream = new S3StreamLogger({
bucket: this.bucket,
folder: 'logs/requests',
name_format: `server-requests-%Y-%m-%d-%H-%M-%S-%L.log.jsonl`,
config: this.s3Config
})
return [new transports.Stream({ stream: s3ServerReqStream })]
}
return []
}
}
@@ -0,0 +1,40 @@
import { IStorageProvider } from './IStorageProvider'
import { LocalStorageProvider } from './LocalStorageProvider'
import { S3StorageProvider } from './S3StorageProvider'
import { GCSStorageProvider } from './GCSStorageProvider'
import { AzureBlobStorageProvider } from './AzureBlobStorageProvider'
/**
* Factory for creating and managing storage provider instances.
* Uses singleton pattern to ensure only one provider instance exists.
*/
export class StorageProviderFactory {
private static instance: IStorageProvider | null = null
/**
* Get the storage provider instance based on STORAGE_TYPE environment variable.
* Creates a new instance if one doesn't exist.
*/
static getProvider(): IStorageProvider {
if (!StorageProviderFactory.instance) {
const storageType = process.env.STORAGE_TYPE || 'local'
switch (storageType) {
case 's3':
StorageProviderFactory.instance = new S3StorageProvider()
break
case 'gcs':
StorageProviderFactory.instance = new GCSStorageProvider()
break
case 'azure':
StorageProviderFactory.instance = new AzureBlobStorageProvider()
break
case 'local':
default:
StorageProviderFactory.instance = new LocalStorageProvider()
break
}
}
return StorageProviderFactory.instance
}
}
+14
View File
@@ -0,0 +1,14 @@
// Interfaces and types
export * from './IStorageProvider'
// Base provider
export { BaseStorageProvider } from './BaseStorageProvider'
// Provider implementations
export { LocalStorageProvider } from './LocalStorageProvider'
export { S3StorageProvider } from './S3StorageProvider'
export { GCSStorageProvider } from './GCSStorageProvider'
export { AzureBlobStorageProvider } from './AzureBlobStorageProvider'
// Factory
export { StorageProviderFactory } from './StorageProviderFactory'
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -49,7 +49,7 @@ PORT=3000
############################################## STORAGE #####################################################
############################################################################################################
# STORAGE_TYPE=local (local | s3 | gcs)
# STORAGE_TYPE=local (local | s3 | gcs | azure)
# BLOB_STORAGE_PATH=/your_storage_path/.flowise/storage
# S3_STORAGE_BUCKET_NAME=flowise
# S3_STORAGE_ACCESS_KEY_ID=<your-access-key>
@@ -61,7 +61,12 @@ PORT=3000
# GOOGLE_CLOUD_STORAGE_PROJ_ID=<your-gcp-project-id>
# GOOGLE_CLOUD_STORAGE_BUCKET_NAME=<the-bucket-name>
# GOOGLE_CLOUD_UNIFORM_BUCKET_ACCESS=true
# Azure Blob Storage (when STORAGE_TYPE=azure)
# Provide EITHER a connection string OR account name + key
# AZURE_BLOB_STORAGE_CONNECTION_STRING=<your-connection-string>
# AZURE_BLOB_STORAGE_ACCOUNT_NAME=<your-account-name>
# AZURE_BLOB_STORAGE_ACCOUNT_KEY=<your-account-key>
# AZURE_BLOB_STORAGE_CONTAINER_NAME=<your-container-name>
############################################################################################################
############################################## SETTINGS ####################################################
+2
View File
@@ -120,6 +120,7 @@
"moment": "^2.29.3",
"moment-timezone": "^0.5.34",
"multer": "^2.0.2",
"multer-azure-blob-storage": "^1.2.0",
"multer-cloud-storage": "^4.0.0",
"multer-s3": "^3.0.1",
"mysql2": "^3.11.3",
@@ -147,6 +148,7 @@
"typeorm": "^0.3.6",
"uuid": "^9.0.1",
"winston": "^3.9.0",
"winston-azure-blob": "^1.5.0",
"winston-daily-rotate-file": "^5.0.0"
},
"devDependencies": {
+4
View File
@@ -68,6 +68,10 @@ export abstract class BaseCommand extends Command {
GOOGLE_CLOUD_STORAGE_PROJ_ID: Flags.string(),
GOOGLE_CLOUD_STORAGE_BUCKET_NAME: Flags.string(),
GOOGLE_CLOUD_UNIFORM_BUCKET_ACCESS: Flags.string(),
AZURE_BLOB_STORAGE_CONNECTION_STRING: Flags.string(),
AZURE_BLOB_STORAGE_ACCOUNT_NAME: Flags.string(),
AZURE_BLOB_STORAGE_ACCOUNT_KEY: Flags.string(),
AZURE_BLOB_STORAGE_CONTAINER_NAME: Flags.string(),
// Credentials / Secret Keys
SECRETKEY_STORAGE_TYPE: Flags.string(),
+4 -36
View File
@@ -38,13 +38,11 @@ import {
IMessage,
FlowiseMemory,
IFileUpload,
getS3Config
StorageProviderFactory
} from 'flowise-components'
import { randomBytes } from 'crypto'
import { AES, enc } from 'crypto-js'
import multer from 'multer'
import multerS3 from 'multer-s3'
import MulterGoogleCloudStorage from 'multer-cloud-storage'
import { ChatFlow } from '../database/entities/ChatFlow'
import { ChatMessage } from '../database/entities/ChatMessage'
import { Credential } from '../database/entities/Credential'
@@ -1997,38 +1995,8 @@ export function generateId() {
}
export const getMulterStorage = () => {
const storageType = process.env.STORAGE_TYPE ? process.env.STORAGE_TYPE : 'local'
if (storageType === 's3') {
const s3Client = getS3Config().s3Client
const Bucket = getS3Config().Bucket
const upload = multer({
storage: multerS3({
s3: s3Client,
bucket: Bucket,
metadata: function (req, file, cb) {
cb(null, { fieldName: file.fieldname, originalName: file.originalname })
},
key: function (req, file, cb) {
cb(null, `${generateId()}`)
}
})
})
return upload
} else if (storageType === 'gcs') {
return multer({
storage: new MulterGoogleCloudStorage({
projectId: process.env.GOOGLE_CLOUD_STORAGE_PROJ_ID,
bucket: process.env.GOOGLE_CLOUD_STORAGE_BUCKET_NAME,
keyFilename: process.env.GOOGLE_CLOUD_STORAGE_CREDENTIAL,
uniformBucketLevelAccess: Boolean(process.env.GOOGLE_CLOUD_UNIFORM_BUCKET_ACCESS) ?? true,
destination: `uploads/${generateId()}`
})
})
} else {
return multer({ dest: getUploadPath() })
}
const provider = StorageProviderFactory.getProvider()
return provider.getMulterStorage()
}
/**
+10 -149
View File
@@ -1,100 +1,18 @@
import * as path from 'path'
import * as fs from 'fs'
import config from './config' // should be replaced by node-config or similar
import { createLogger, transports, format } from 'winston'
import { NextFunction, Request, Response } from 'express'
import DailyRotateFile from 'winston-daily-rotate-file'
import { S3ClientConfig } from '@aws-sdk/client-s3'
import { LoggingWinston } from '@google-cloud/logging-winston'
const { S3StreamLogger } = require('s3-streamlogger')
import { StorageProviderFactory } from 'flowise-components'
const { combine, timestamp, printf, errors } = format
let s3ServerStream: any
let s3ErrorStream: any
let s3ServerReqStream: any
let gcsServerStream: any
let gcsErrorStream: any
let gcsServerReqStream: any
let requestLogger: any
if (process.env.STORAGE_TYPE === 's3') {
const accessKeyId = process.env.S3_STORAGE_ACCESS_KEY_ID
const secretAccessKey = process.env.S3_STORAGE_SECRET_ACCESS_KEY
const region = process.env.S3_STORAGE_REGION
const s3Bucket = process.env.S3_STORAGE_BUCKET_NAME
const customURL = process.env.S3_ENDPOINT_URL
const forcePathStyle = process.env.S3_FORCE_PATH_STYLE === 'true'
const provider = StorageProviderFactory.getProvider()
const serverTransports = provider.getLoggerTransports('server', config)
const errorTransports = provider.getLoggerTransports('error', config)
const requestTransports = provider.getLoggerTransports('requests', config)
if (!region || region.trim() === '' || !s3Bucket || s3Bucket.trim() === '') {
throw new Error('S3 storage configuration is missing')
}
const s3Config: S3ClientConfig = {
region: region,
forcePathStyle: forcePathStyle
}
// Only include endpoint if customURL is not empty
if (customURL && customURL.trim() !== '') {
s3Config.endpoint = customURL
}
if (accessKeyId && accessKeyId.trim() !== '' && secretAccessKey && secretAccessKey.trim() !== '') {
s3Config.credentials = {
accessKeyId: accessKeyId,
secretAccessKey: secretAccessKey
}
}
s3ServerStream = new S3StreamLogger({
bucket: s3Bucket,
folder: 'logs/server',
name_format: `server-%Y-%m-%d-%H-%M-%S-%L.log`,
config: s3Config
})
s3ErrorStream = new S3StreamLogger({
bucket: s3Bucket,
folder: 'logs/error',
name_format: `server-error-%Y-%m-%d-%H-%M-%S-%L.log`,
config: s3Config
})
s3ServerReqStream = new S3StreamLogger({
bucket: s3Bucket,
folder: 'logs/requests',
name_format: `server-requests-%Y-%m-%d-%H-%M-%S-%L.log.jsonl`,
config: s3Config
})
}
if (process.env.STORAGE_TYPE === 'gcs') {
const config = {
projectId: process.env.GOOGLE_CLOUD_STORAGE_PROJ_ID,
keyFilename: process.env.GOOGLE_CLOUD_STORAGE_CREDENTIAL,
defaultCallback: (err: any) => {
if (err) {
console.error('Error logging to GCS: ' + err)
}
}
}
gcsServerStream = new LoggingWinston({
...config,
logName: 'server'
})
gcsErrorStream = new LoggingWinston({
...config,
logName: 'error'
})
gcsServerReqStream = new LoggingWinston({
...config,
logName: 'requests'
})
}
// expect the log dir be relative to the projects root
const logDir = config.logging.dir
@@ -117,52 +35,13 @@ const logger = createLogger({
package: 'server'
},
exitOnError: false,
transports: [
new transports.Console(),
...(!process.env.STORAGE_TYPE || process.env.STORAGE_TYPE === 'local'
? [
new DailyRotateFile({
filename: path.join(logDir, config.logging.server.filename ?? 'server-%DATE%.log'),
datePattern: 'YYYY-MM-DD-HH',
maxSize: '20m',
level: config.logging.server.level ?? 'info'
})
]
: []),
...(process.env.STORAGE_TYPE === 's3'
? [
new transports.Stream({
stream: s3ServerStream
})
]
: []),
...(process.env.STORAGE_TYPE === 'gcs' ? [gcsServerStream] : [])
],
exceptionHandlers: [
...(process.env.DEBUG && process.env.DEBUG === 'true' ? [new transports.Console()] : []),
...(process.env.STORAGE_TYPE === 's3'
? [
new transports.Stream({
stream: s3ErrorStream
})
]
: []),
...(process.env.STORAGE_TYPE === 'gcs' ? [gcsErrorStream] : [])
],
transports: [new transports.Console(), ...serverTransports],
exceptionHandlers: [...(process.env.DEBUG && process.env.DEBUG === 'true' ? [new transports.Console()] : []), ...errorTransports],
rejectionHandlers: [
...(process.env.DEBUG && process.env.DEBUG === 'true' ? [new transports.Console()] : []),
...(process.env.STORAGE_TYPE === 's3'
? [
new transports.Stream({
stream: s3ErrorStream
})
]
: []),
...(process.env.STORAGE_TYPE === 'gcs' ? [gcsErrorStream] : []),
...errorTransports,
// Always provide a fallback rejection handler when no other handlers are configured
...((!process.env.DEBUG || process.env.DEBUG !== 'true') && process.env.STORAGE_TYPE !== 's3' && process.env.STORAGE_TYPE !== 'gcs'
? [new transports.Console()]
: [])
...((!process.env.DEBUG || process.env.DEBUG !== 'true') && errorTransports.length === 0 ? [new transports.Console()] : [])
]
})
@@ -171,25 +50,7 @@ requestLogger = createLogger({
defaultMeta: {
package: 'server'
},
transports: [
...(process.env.DEBUG && process.env.DEBUG === 'true' ? [new transports.Console()] : []),
...(!process.env.STORAGE_TYPE || process.env.STORAGE_TYPE === 'local'
? [
new transports.File({
filename: path.join(logDir, config.logging.express.filename ?? 'server-requests.log.jsonl'),
level: config.logging.express.level ?? 'debug'
})
]
: []),
...(process.env.STORAGE_TYPE === 's3'
? [
new transports.Stream({
stream: s3ServerReqStream
})
]
: []),
...(process.env.STORAGE_TYPE === 'gcs' ? [gcsServerReqStream] : [])
]
transports: [...(process.env.DEBUG && process.env.DEBUG === 'true' ? [new transports.Console()] : []), ...requestTransports]
})
function getSensitiveBodyFields(): string[] {
+528 -14
View File
File diff suppressed because it is too large Load Diff