diff --git a/config/setup/en-US/connector.tpl b/config/setup/en-US/connector.tpl index 033505dcc..66168fc64 100644 --- a/config/setup/en-US/connector.tpl +++ b/config/setup/en-US/connector.tpl @@ -732,4 +732,49 @@ POST $[[SETUP_INDEX_PREFIX]]connector$[[SETUP_SCHEMA_VER]]/$[[SETUP_DOC_TYPE]]/g "processor": { "enabled": false } +} + +POST $[[SETUP_INDEX_PREFIX]]connector$[[SETUP_SCHEMA_VER]]/$[[SETUP_DOC_TYPE]]/box +{ + "_system": { + "owner_id": "$[[SETUP_OWNER_ID]]" + }, + "id" : "box", + "created" : "2025-11-02T00:00:00.000000+08:00", + "updated" : "2025-11-02T00:00:00.000000+08:00", + "name" : "Box Cloud Storage Connector", + "description" : "Index files and folders from Box, supporting both Free and Enterprise accounts with multi-user access.", + "category" : "cloud_storage", + "path_hierarchy" : false, + "icon" : "/assets/icons/connector/box/icon.png", + "tags" : [ + "box", + "cloud_storage", + "file_sharing" + ], + "url" : "http://coco.rs/connectors/box", + "assets" : { + "icons" : { + "default" : "/assets/icons/connector/box/icon.png", + "bookmark" : "/assets/icons/connector/box/bookmark.png", + "boxcanvas" : "/assets/icons/connector/box/boxcanvas.png", + "boxnote" : "/assets/icons/connector/box/boxnote.png", + "docx" : "/assets/icons/connector/box/docx.png", + "excel-spreadsheet" : "/assets/icons/connector/box/excel-spreadsheet.png", + "google-docs" : "/assets/icons/connector/box/google-docs.png", + "google-sheets" : "/assets/icons/connector/box/google-sheets.png", + "google-slides" : "/assets/icons/connector/box/google-slides.png", + "keynote" : "/assets/icons/connector/box/keynote.png", + "numbers" : "/assets/icons/connector/box/numbers.png", + "pages" : "/assets/icons/connector/box/pages.png", + "pdf" : "/assets/icons/connector/box/pdf.png", + "powerpoint-presentation" : "/assets/icons/connector/box/powerpoint-presentation.png" + } + }, + "builtin": true, + "oauth_connect_implemented": true, + "processor": { + "enabled": true, + "name": "box" + } } \ No newline at end of file diff --git a/config/setup/zh-CN/connector.tpl b/config/setup/zh-CN/connector.tpl index 84349efd1..d6fd7908b 100644 --- a/config/setup/zh-CN/connector.tpl +++ b/config/setup/zh-CN/connector.tpl @@ -734,4 +734,52 @@ POST $[[SETUP_INDEX_PREFIX]]connector$[[SETUP_SCHEMA_VER]]/$[[SETUP_DOC_TYPE]]/g "processor": { "enabled": false } +} + +POST $[[SETUP_INDEX_PREFIX]]connector$[[SETUP_SCHEMA_VER]]/$[[SETUP_DOC_TYPE]]/box +{ + "_system": { + "owner_id": "$[[SETUP_OWNER_ID]]" + }, + "id" : "box", + "created" : "2025-11-02T00:00:00.000000+08:00", + "updated" : "2025-11-02T00:00:00.000000+08:00", + "name" : "Box 云存储连接器", + "description" : "索引 Box 中的文件和文件夹,支持免费账号和企业账号的多用户访问。", + "category" : "cloud_storage", + "path_hierarchy" : false, + "icon" : "/assets/icons/connector/box/icon.png", + "tags" : [ + "box", + "cloud_storage", + "file_sharing" + ], + "url" : "http://coco.rs/connectors/box", + "assets" : { + "icons" : { + "default" : "/assets/icons/connector/box/icon.png", + "bookmark" : "/assets/icons/connector/box/bookmark.png", + "boxcanvas" : "/assets/icons/connector/box/boxcanvas.png", + "boxnote" : "/assets/icons/connector/box/boxnote.png", + "docx" : "/assets/icons/connector/box/docx.png", + "excel-spreadsheet" : "/assets/icons/connector/box/excel-spreadsheet.png", + "google-docs" : "/assets/icons/connector/box/google-docs.png", + "google-sheets" : "/assets/icons/connector/box/google-sheets.png", + "google-slides" : "/assets/icons/connector/box/google-slides.png", + "keynote" : "/assets/icons/connector/box/keynote.png", + "numbers" : "/assets/icons/connector/box/numbers.png", + "pages" : "/assets/icons/connector/box/pages.png", + "pdf" : "/assets/icons/connector/box/pdf.png", + "powerpoint-presentation" : "/assets/icons/connector/box/powerpoint-presentation.png" + } + }, + "config": { + "redirect_uri": "$[[SETUP_SERVER_ENDPOINT]]/connector/box/oauth_redirect" + }, + "builtin": true, + "oauth_connect_implemented": true, + "processor": { + "enabled": true, + "name": "box" + } } \ No newline at end of file diff --git a/config/store/infinilabs/connector/box/assets/screenshot.png b/config/store/infinilabs/connector/box/assets/screenshot.png new file mode 100644 index 000000000..3c0b5e021 Binary files /dev/null and b/config/store/infinilabs/connector/box/assets/screenshot.png differ diff --git a/config/store/infinilabs/connector/box/plugin.json b/config/store/infinilabs/connector/box/plugin.json new file mode 100644 index 000000000..5432d3fa8 --- /dev/null +++ b/config/store/infinilabs/connector/box/plugin.json @@ -0,0 +1,75 @@ +{ + "category": "cloud_storage", + "description": "Index files and folders from Box, supporting both Free and Enterprise accounts with multi-user access.", + "developer": { + "avatar": "https://coco.infini.cloud/extensions/infinilabs/assets/avatar.png", + "github_handle": "infinilabs", + "id": "infinilabs", + "location": "Internet", + "name": "infinilabs", + "website": "https://github.com/infinilabs" + }, + "icon": "/assets/icons/connector/box/icon.png", + "name": "Box Cloud Storage Connector", + "platforms": [ + "macos", + "linux", + "windows" + ], + "screenshots": [ + { + "title": "Box Cloud Storage Connector", + "url": "/assets/screenshot.png" + } + ], + "tags": [ + "box", + "cloud_storage", + "file_sharing" + ], + "type": "connector", + "url": { + "home": "http://coco.rs/connectors/box" + }, + "version": { + "number": "0.1" + }, + "payload": { + "name": "Box Cloud Storage Connector", + "description": "Index files and folders from Box, supporting both Free and Enterprise accounts with multi-user access.", + "category": "cloud_storage", + "icon": "/assets/icons/connector/box/icon.png", + "tags": [ + "box", + "cloud_storage", + "file_sharing" + ], + "url": "http://coco.rs/connectors/box", + "assets": { + "icons": { + "default": "/assets/icons/connector/box/icon.png", + "bookmark": "/assets/icons/connector/box/bookmark.png", + "boxcanvas": "/assets/icons/connector/box/boxcanvas.png", + "boxnote": "/assets/icons/connector/box/boxnote.png", + "docx": "/assets/icons/connector/box/docx.png", + "excel-spreadsheet": "/assets/icons/connector/box/excel-spreadsheet.png", + "google-docs": "/assets/icons/connector/box/google-docs.png", + "google-sheets": "/assets/icons/connector/box/google-sheets.png", + "google-slides": "/assets/icons/connector/box/google-slides.png", + "keynote": "/assets/icons/connector/box/keynote.png", + "numbers": "/assets/icons/connector/box/numbers.png", + "pages": "/assets/icons/connector/box/pages.png", + "pdf": "/assets/icons/connector/box/pdf.png", + "powerpoint-presentation": "/assets/icons/connector/box/powerpoint-presentation.png" + } + }, + "builtin": true, + "path_hierarchy": false, + "oauth_connect_implemented": true, + "processor": { + "enabled": true, + "name": "box" + } + } +} + diff --git a/docs/content.en/docs/references/connectors/box.md b/docs/content.en/docs/references/connectors/box.md new file mode 100644 index 000000000..8ca1a849b --- /dev/null +++ b/docs/content.en/docs/references/connectors/box.md @@ -0,0 +1,377 @@ +--- +title: "Box" +weight: 65 +--- + +# Box Cloud Storage Connector + +## Register Box Connector + +```shell +curl -XPOST "http://localhost:9000/connector/" -d '{ + "name": "Box Cloud Storage Connector", + "description": "Index files and folders from Box, supporting both Free and Enterprise accounts with multi-user access.", + "icon": "/assets/icons/connector/box/icon.png", + "category": "cloud_storage", + "path_hierarchy": false, + "tags": [ + "box", + "cloud_storage", + "file_sharing" + ], + "url": "http://coco.rs/connectors/box", + "assets": { + "icons": { + "default": "/assets/icons/connector/box/icon.png", + "bookmark": "/assets/icons/connector/box/bookmark.png", + "boxcanvas": "/assets/icons/connector/box/boxcanvas.png", + "boxnote": "/assets/icons/connector/box/boxnote.png", + "docx": "/assets/icons/connector/box/docx.png", + "excel-spreadsheet": "/assets/icons/connector/box/excel-spreadsheet.png", + "google-docs": "/assets/icons/connector/box/google-docs.png", + "google-sheets": "/assets/icons/connector/box/google-sheets.png", + "google-slides": "/assets/icons/connector/box/google-slides.png", + "keynote": "/assets/icons/connector/box/keynote.png", + "numbers": "/assets/icons/connector/box/numbers.png", + "pages": "/assets/icons/connector/box/pages.png", + "pdf": "/assets/icons/connector/box/pdf.png", + "powerpoint-presentation": "/assets/icons/connector/box/powerpoint-presentation.png" + } + }, + "processor": { + "enabled": true, + "name": "box" + } +}' +``` + +## Use the Box Connector + +The Box Connector allows you to index files and folders from Box cloud storage with support for both Free and Enterprise accounts. + +### Features + +- **Dual Account Support**: Works with both Box Free Account and Box Enterprise Account +- **Multi-User Access**: Enterprise accounts can index files from all users +- **Hierarchical Structure**: Maintains original folder structure with path hierarchy +- **Automatic Token Management**: Built-in token caching and auto-refresh +- **Recursive Folder Processing**: Automatically processes all subfolders +- **Enterprise User Categorization**: Files from different users are properly categorized +- **Metadata Extraction**: Extracts comprehensive file and folder metadata +- **Pipeline Integration**: Full pipeline-based architecture for efficient syncing + +### Account Types + +#### Box Free Account + +**Authentication**: OAuth 2.0 Refresh Token Flow +- **Access Scope**: Current authenticated user's files only +- **Token Management**: Backend automatically obtains and saves `refresh_token` through OAuth flow +- **OAuth Flow**: Built-in OAuth flow via UI endpoints - no manual token configuration needed +- **Use Case**: Personal file indexing + +#### Box Enterprise Account + +**Authentication**: OAuth 2.0 Client Credentials Flow +- **Access Scope**: All users' files in the enterprise +- **Multi-User Support**: Automatically fetches files from all enterprise users +- **Use Case**: Organization-wide file indexing + +### Setup Box Application + +Before using this connector, you need to create a Box application and configure OAuth2. + +#### 1. Create a Box Application + +1. **Visit Box Developer Console** + - Go to [Box Developer Console](https://app.box.com/developers/console) + - Sign in with your Box account + +2. **Create New App** + - Click "Create New App" + - Choose "Custom App" + - Select authentication method: + - For **Free Account**: Choose "Standard OAuth 2.0 (User Authentication)" + - For **Enterprise Account**: Choose "OAuth 2.0 with JWT (Server Authentication)" or "Server Authentication (with Client Credentials Grant)" + +3. **Configure Application** + - Enter application name + - Enter application description + - Configure redirect URI (if using OAuth flow for token generation) + +4. **Get Credentials** + - Copy `Client ID` from Configuration page + - Copy `Client Secret` from Configuration page + - For Enterprise: Copy `Enterprise ID` from Admin Console + +#### 2. Required Scopes + +For proper functionality, the Box application needs: + +**For Free Account:** +- Read files and folders +- User information + +**For Enterprise Account:** +- Manage users +- Manage enterprise content +- Read all files and folders + +#### 3. Application Approval + +- For Enterprise accounts, the application must be approved by Box administrator +- Ensure the application is published and authorized + +### Access Connector Settings + +1. Navigate to the **Data Sources** section in your Coco dashboard +2. Create a new data source or edit an existing Box data source +3. Configure the required credentials based on your account type + +> **⚠️ Important**: Before you can use the Box connector, you must configure the following required parameters based on your account type: +> +> **For Box Free Account:** +> - `is_enterprise`: Set to "box_free" (or omit, defaults to "box_free") +> - `client_id`: OAuth2 client ID from your Box application +> - `client_secret`: OAuth2 client secret from your Box application +> - `refresh_token`: **Automatically obtained and saved by backend through OAuth flow - you do NOT need to configure this manually** +> +> **For Box Enterprise Account:** +> - `is_enterprise`: Set to "box_enterprise" +> - `client_id`: OAuth2 client ID from your Box application +> - `client_secret`: OAuth2 client secret from your Box application +> - `enterprise_id`: Your Box Enterprise ID + +### Datasource Configuration + +#### Box Free Account Example + +**Using OAuth Flow (Required)** + +1. Configure the connector with `client_id` and `client_secret` in connector settings +2. Use the OAuth flow via UI: Navigate to connector detail page and click "Connect" +3. The backend will automatically: + - Exchange authorization code for access and refresh tokens + - Save `refresh_token` to the datasource configuration + - Create the datasource with proper configuration + +> **Note**: For Free Account, `refresh_token` is **automatically obtained and saved by the backend** through the OAuth flow. You do NOT need to manually configure or provide `refresh_token`. Simply use the OAuth flow via the UI. + +#### Box Enterprise Account Example + +```shell +curl -H 'Content-Type: application/json' -XPOST "http://localhost:9000/datasource/" -d '{ + "name": "Company Box Files", + "type": "connector", + "enabled": true, + "connector": { + "id": "box", + "config": { + "is_enterprise": "box_enterprise", + "client_id": "your_client_id", + "client_secret": "your_client_secret", + "enterprise_id": "12345", + "concurrent_downloads": 15 + } + }, + "sync": { + "enabled": true, + "interval": "5m" + } +}' +``` + +### Datasource Config Parameters + +| **Field** | **Type** | **Description** | **Required** | **Account Type** | +|-------------------------|-----------|-----------------------------------------------------------------------------------|--------------|------------------| +| `is_enterprise` | `string` | Account type: "box_free" or "box_enterprise" | Yes | Both | +| `client_id` | `string` | Box application Client ID | Yes | Both | +| `client_secret` | `string` | Box application Client Secret | Yes | Both | +| `refresh_token` | `string` | OAuth refresh token (automatically obtained and saved by backend via OAuth flow - **do NOT configure manually**) | Auto-managed | Free only | +| `enterprise_id` | `string` | Box Enterprise ID (for Enterprise account) | Yes | Enterprise only | +| `concurrent_downloads` | `int` | Maximum concurrent downloads (default: 15) | No | Both | +| `sync.enabled` | `boolean` | Enable/disable syncing for this datasource | No | Both | +| `sync.interval` | `string` | Sync interval (e.g., "30s", "5m", "1h") | No | Both | + +## File Hierarchy + +### Box Free Account + +Files are organized directly from root: + +``` +/ +├── Documents/ +│ ├── report.pdf +│ └── 2024/ +│ └── annual-report.pdf +├── Photos/ +│ └── image.jpg +└── Shared/ + └── presentation.pptx +``` + +### Box Enterprise Account + +Files are organized by user name to separate content from different users: + +``` +/ +├── John Doe/ +│ ├── Documents/ +│ │ └── report.pdf +│ └── Photos/ +│ └── image.jpg +├── Jane Smith/ +│ ├── Documents/ +│ │ └── report.pdf +│ └── Reports/ +│ └── sales.xlsx +└── Bob Johnson/ + └── Presentations/ + └── deck.pptx +``` + +**Key Points:** +- Each user's files are under their name category +- Document IDs include user ID to avoid conflicts +- Metadata includes `user_id` field for filtering + +## Advanced Features + +### Automatic Token Management + +The connector implements intelligent token management: + +- **Token Caching**: In-memory cache with thread-safe operations +- **Expiry Buffer**: Refreshes tokens 5 minutes before expiry +- **Automatic Refresh**: Transparent token refresh on expiration +- **401 Retry**: Automatic re-authentication on unauthorized errors +- **Refresh Token Rotation**: Supports refresh token rotation (Free account) + +### Multi-User Support (Enterprise) + +For Enterprise accounts, the connector: + +1. **Fetches All Users**: Automatically retrieves all users in the enterprise +2. **Per-User Processing**: Processes files for each user independently +3. **As-User Header**: Uses `as-user` header to access files as specific users +4. **User Categorization**: Organizes files under user names in hierarchy +5. **Unique Document IDs**: Generates unique IDs including user ID to avoid conflicts + +### Metadata Extraction + +The connector extracts comprehensive metadata: + +**File Metadata:** +- File ID, Name, Type, Size +- Creation and modification timestamps +- Description and status +- Creator, modifier, and owner information +- Parent folder information +- ETag and sequence ID +- URLs (direct, download, thumbnail) +- Shared link information + +**Folder Metadata:** +- Folder ID, Name, Type +- Creation and modification timestamps +- Size and hierarchy information +- Platform identifier + +## Troubleshooting + +### Common Issues + +1. **Authentication Failed** + - **Free Account**: Verify `client_id` and `client_secret` are correct. Ensure OAuth flow completed successfully (backend automatically saves `refresh_token`). + - **Enterprise Account**: Verify `client_id`, `client_secret`, and `enterprise_id` are correct + - Check if Box application is approved and published + - Ensure application has required scopes + +2. **Token Expired** + - System automatically refreshes tokens + - **Free Account**: If refresh_token is invalid, re-run OAuth flow to obtain a new one + - **Enterprise Account**: Verify application credentials haven't changed + - Review token expiry settings + +3. **No Files Found** + - Check user permissions in Box + - Verify application has file access permissions + - **Enterprise**: Ensure users have files in their accounts + - Check folder access permissions + +4. **Multi-User Issues (Enterprise)** + - Verify application has "Manage Users" permission + - Check if users are active in the enterprise + - Ensure `as-user` header is supported by your application type + +5. **Sync Failures** + - Check network connectivity to `https://api.box.com` + - Verify API rate limits aren't exceeded + - Review system logs for detailed error messages + - Check datasource sync interval settings + +### Debug Logging + +The connector provides detailed logging: +- `[box connector]`: Main connector operations +- `[box client]`: API client operations +- Authentication process and token refresh +- User enumeration (Enterprise) +- File and folder processing +- API requests and errors + +Use logs to quickly identify and resolve issues. + +## Notes + +1. **Account Type Selection**: Must specify either "box_free" or "box_enterprise" (defaults to "box_free" if not specified) +2. **Different Credentials**: Free and Enterprise accounts require different configuration +3. **OAuth Flow Required**: Free accounts **must** use the built-in OAuth flow via UI endpoints - backend automatically obtains and saves `refresh_token`. You do NOT need to manually configure `refresh_token`. +4. **No Manual Token Configuration**: For Free accounts, `refresh_token` is completely managed by the backend - you never need to provide or configure it manually +5. **Enterprise ID Requirement**: Enterprise accounts must have a valid enterprise_id +6. **Multi-User Automatic**: Enterprise accounts automatically fetch files from all users +7. **Token Auto-Refresh**: All tokens are automatically managed and refreshed +8. **Content Extraction**: File content extraction is handled by coco-server framework +9. **API Rate Limits**: Be aware of Box API rate limits (typically 100 requests/minute) +10. **File Size Limits**: Large files may be excluded based on framework configuration +11. **Hierarchical Path**: Connector preserves original folder structure with `/` as root +12. **Path Hierarchy**: Set to `false` - connector uses category hierarchy instead of path hierarchy + +## API Endpoints Used + +The connector uses the following Box API endpoints: + +| Endpoint | Purpose | Account Type | +|----------|---------|--------------| +| `/oauth2/token` | Authentication and token refresh | Both | +| `/2.0/users/me` | Ping test and user info | Both | +| `/2.0/users` | Fetch enterprise users | Enterprise only | +| `/2.0/folders/{id}/items` | List folder contents | Both | + +All API calls include automatic retry on 401 errors and support for the `as-user` header in Enterprise accounts. + +## OAuth Flow (Free Account) + +The Box connector provides built-in OAuth flow for Free accounts. **This is the only way to set up a Free Account datasource.** + +### Setup Steps: + +1. **Configure Connector**: Set `client_id` and `client_secret` in connector settings +2. **Initiate OAuth**: Navigate to connector detail page and click "Connect" or visit `/connector/{connector_id}/box/connect` +3. **Authorize**: You will be redirected to Box authorization page to authorize the application +4. **Automatic Setup**: Backend automatically: + - Exchanges authorization code for access and refresh tokens + - Saves `refresh_token` to datasource configuration (you don't need to do anything) + - Creates datasource with proper configuration + - Caches authenticated client for future use + +> **Important**: You do NOT need to manually configure `refresh_token`. The backend handles everything automatically through the OAuth flow. + +### OAuth Endpoints: + +- `GET /connector/:id/box/connect` - Initiates OAuth flow +- `GET /connector/:id/box/oauth_redirect` - Handles OAuth callback + diff --git a/plugins/connectors/box/README.md b/plugins/connectors/box/README.md new file mode 100644 index 000000000..a70c45a69 --- /dev/null +++ b/plugins/connectors/box/README.md @@ -0,0 +1,255 @@ +# Box Cloud Storage Connector + +Box cloud storage connector is used to index files and folders from Box, supporting both Free and Enterprise accounts. + +## Features + +- 🔍 **Smart Search**: Search files and folders by keywords +- 📁 **Hierarchical Structure**: Maintains original folder structure with path hierarchy +- 🔐 **Dual Account Support**: Supports both Box Free Account and Box Enterprise Account +- 👥 **Multi-User Support**: Enterprise accounts can index files from all users +- ⚡ **Efficient Sync**: Pipeline-based architecture with unified scheduler +- 🔄 **Recursive Folder Processing**: Automatically processes all subfolders +- 🔄 **Automatic Token Refresh**: Built-in token caching and auto-refresh mechanism +- 🏗️ **Unified Architecture**: Follows coco-server connector standards + +## Account Types + +### Box Free Account +- **Access Scope**: Current authenticated user's files only +- **Required Credentials**: + - `client_id` + - `client_secret` + +### Box Enterprise Account +- **Authentication**: OAuth 2.0 client credentials +- **Access Scope**: All users' files in the enterprise +- **Required Credentials**: + - `client_id` + - `client_secret` + - `enterprise_id` + +## Configuration + +### Required Parameters + +| Parameter | Type | Description | Account Type | +|-----------|------|-------------|--------------| +| `is_enterprise` | string | Account type: "box_free" or "box_enterprise" | Both | +| `client_id` | string | Box application Client ID | Both | +| `client_secret` | string | Box application Client Secret | Both | +| `enterprise_id` | string | Box Enterprise ID | Enterprise Account only | + +### Sync Configuration + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `sync.enabled` | bool | true | Enable synchronization | +| `sync.interval` | string | "30s" | Sync interval per datasource | + +**Note**: Sync interval is configured at datasource level, not connector level. Each datasource can have different sync intervals. + +## Box Application Setup + +### Creating a Box Application + +1. **Visit Box Developer Console** + - Go to [Box Developer Console](https://app.box.com/developers/console) + - Sign in with your Box account + +2. **Create New App** + - Click "Create New App" + - Choose "Custom App" + - Select "OAuth 2.0 with JWT (Server Authentication)" for Enterprise or "Standard OAuth 2.0 (User Authentication)" for Free account + - Enter app name and description + +3. **Configure OAuth** + - For Free Account: Enable "Authorization Code Grant" and "Refresh Token" + - For Enterprise Account: Enable "Client Credentials Grant" + - Add redirect URI (if using OAuth flow) + +4. **Get Credentials** + - Copy `Client ID` from app configuration + - Copy `Client Secret` from app configuration + - For Enterprise: Copy `Enterprise ID` from account settings + +### Required Scopes + +For proper functionality, the Box application needs: + +- **Read files and folders**: Access to read file metadata and folder structure +- **User information**: Read user profile information (for Free account) +- **Enterprise content**: Access enterprise content (for Enterprise account) + +## Usage + +### Method 1: Box Free Account + +#### Step 1: Obtain client_id and client_secret +You need to obtain a client_id and client_secret through OAuth flow first (this can be done outside the system or through a separate OAuth setup). + +#### Step 2: Configure Datasource +```json +{ + "id": "my-box-free", + "name": "My Box Files", + "type": "connector", + "enabled": true, + "sync": { + "enabled": true, + "interval": "30s" + }, + "connector": { + "id": "box", + "config": { + "client_id": "your_client_id", + "client_secret": "your_client_secret", + } + } +} +``` + +### Method 2: Box Enterprise Account + +#### Step 1: Get Enterprise Credentials +1. Obtain Client ID and Client Secret from Box Developer Console +2. Get Enterprise ID from Box Admin Console + +#### Step 2: Configure Datasource +```json +{ + "id": "my-box-enterprise", + "name": "Company Box Files", + "type": "connector", + "enabled": true, + "sync": { + "enabled": true, + "interval": "30s" + }, + "connector": { + "id": "box", + "config": { + "is_enterprise": "box_enterprise", + "client_id": "your_client_id", + "client_secret": "your_client_secret", + "enterprise_id": "12345" + } + } +} +``` + +## Architecture + +### Pipeline Architecture + +Box connector adopts **pipeline-based architecture**, consistent with other connectors: + +- **Processor Registration**: Registered as pipeline processor in `init()` function +- **Scheduler Management**: Sync interval managed by connector_dispatcher +- **Per-Datasource Configuration**: Each datasource has independent sync interval and config +- **No Independent Scheduler**: Completely uses pipeline framework for data fetching + +### Core Implementation + +```go +func init() { + // Register pipeline processor + pipeline.RegisterProcessorPlugin(NAME, New) +} + +func (processor *Processor) Fetch(ctx *pipeline.Context, connector *common.Connector, datasource *common.DataSource) error { + // Validate configuration + // Create Box client + // Authenticate with Box + // Process files recursively + return nil +} +``` + +### File Hierarchy + +Box connector preserves the original folder structure: + +- **Root Path**: `/` +- **Enterprise Account**: Each user's files are organized under user name + - `/John Doe/Documents/file.pdf` + - `/Jane Smith/Reports/report.xlsx` +- **Free Account**: Files organized directly from root + - `/Documents/file.pdf` + - `/Photos/image.jpg` + +## Technical Details + +### Authentication Flow + +#### Free Account +``` +1. Cache access_token with expiry time +2. Auto-refresh when token expires +``` + +#### Enterprise Account +``` +1. Use client_credentials grant +2. Request with enterprise_id and box_subject_type +3. Cache access_token with expiry time +4. Auto-refresh when token expires +``` + +### Multi-User Support (Enterprise) + +For Enterprise accounts: +1. Fetch all users from `/2.0/users` endpoint +2. For each user, fetch files with `as-user` header +3. Organize files under user's name in hierarchy +4. Generate unique document IDs including user ID + +### Token Management + +- **Token Caching**: In-memory cache with thread-safe operations +- **Automatic Refresh**: Tokens refresh 5 minutes before expiry +- **401 Retry**: Automatic re-authentication on 401 errors +- **Refresh Token Rotation**: Supports refresh token rotation (Free account) + +## Troubleshooting + +### Common Issues + +1. **Authentication Failed** + - Verify `client_id` and `client_secret` are correct + - Check if Box application is approved and published + - For Enterprise account: Verify `enterprise_id` is correct + +2. **Token Expired** + - System automatically refreshes tokens + - Verify application credentials haven't changed + +3. **No Files Found** + - Check user permissions in Box + - Verify application has required scopes + - For Enterprise: Ensure users have files in their accounts + +4. **Sync Failures** + - Check network connectivity to `https://api.box.com` + - Verify rate limits aren't exceeded + - Review system logs for detailed error messages + +### Debug Logging + +The connector provides detailed logging: +- Authentication process +- Token refresh operations +- API requests and responses +- User enumeration (Enterprise) +- File processing progress + +Use logs to quickly identify and resolve issues. + +## Notes + +1. **Account Type Selection**: Must choose either "box_free" or "box_enterprise" +2. **Credentials Required**: Different account types require different credentials +3. **Enterprise Multi-User**: Enterprise accounts automatically fetch files from all users +4. **Token Management**: Tokens are automatically managed, no manual refresh needed +5. **API Rate Limits**: Be aware of Box API rate limits +6. **Content Extraction**: File content extraction is handled by coco-server framework diff --git a/plugins/connectors/box/api.go b/plugins/connectors/box/api.go new file mode 100644 index 000000000..f769de24d --- /dev/null +++ b/plugins/connectors/box/api.go @@ -0,0 +1,348 @@ +/* Copyright © INFINI LTD. All rights reserved. + * Web: https://infinilabs.com + * Email: hello#infini.ltd */ + +package box + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "infini.sh/coco/core" + httprouter "infini.sh/framework/core/api/router" + "infini.sh/framework/core/orm" + "infini.sh/framework/core/util" + + log "github.com/cihub/seelog" +) + +// Global client cache to store authenticated Box clients +var ( + clientCache sync.Map // key: datasource ID, value: *BoxClient +) + +// GetCachedClient retrieves a cached Box client for a datasource +func GetCachedClient(datasourceID string) (*BoxClient, bool) { + if client, ok := clientCache.Load(datasourceID); ok { + if boxClient, ok := client.(*BoxClient); ok { + return boxClient, true + } + } + return nil, false +} + +// CacheClient stores a Box client for a datasource +func CacheClient(datasourceID string, client *BoxClient) { + clientCache.Store(datasourceID, client) + log.Debugf("[box connector] Cached client for datasource: %s", datasourceID) +} + +// RemoveCachedClient removes a cached Box client for a datasource +func RemoveCachedClient(datasourceID string) { + clientCache.Delete(datasourceID) + log.Debugf("[box connector] Removed cached client for datasource: %s", datasourceID) +} + +// OAuthConfig represents Box OAuth configuration +type OAuthConfig struct { + ClientID string + ClientSecret string + RedirectURL string +} + +// getOAuthConfigFromConnector retrieves OAuth configuration from connector +func getOAuthConfigFromConnector(connectorID string) (*OAuthConfig, error) { + if connectorID == "" { + return nil, fmt.Errorf("connector id is empty") + } + + oauthConfig := &OAuthConfig{ + RedirectURL: fmt.Sprintf("/connector/%s/box/oauth_redirect", connectorID), + } + + // Try to load connector to get OAuth credentials + connector := core.Connector{} + connector.ID = connectorID + exists, err := orm.Get(&connector) + if err == nil && exists && connector.Config != nil { + if clientID, ok := connector.Config["client_id"].(string); ok { + oauthConfig.ClientID = clientID + } + if clientSecret, ok := connector.Config["client_secret"].(string); ok { + oauthConfig.ClientSecret = clientSecret + } + } + + return oauthConfig, nil +} + +// resolveRedirectURL builds full redirect URL from current request +func resolveRedirectURL(oauthConfig *OAuthConfig, req *http.Request) string { + // Build full redirect_url from current request + redirectURL := oauthConfig.RedirectURL + if !strings.HasPrefix(redirectURL, "http://") && !strings.HasPrefix(redirectURL, "https://") { + // Extract scheme and host from current request + scheme := "http" + if req.TLS != nil || req.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + + host := req.Host + if host == "" { + host = "localhost:9000" // fallback + } + + redirectURL = fmt.Sprintf("%s://%s%s", scheme, host, redirectURL) + oauthConfig.RedirectURL = redirectURL + } + return redirectURL +} + +// connect initiates the OAuth flow by redirecting to Box authorization page +func connect(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { + connectorID := ps.MustGetParameter("id") + + oauthConfig, err := getOAuthConfigFromConnector(connectorID) + if err != nil { + http.Error(w, fmt.Sprintf("OAuth config error: %v", err), http.StatusBadRequest) + return + } + + // Check if OAuth is properly configured in connector + if oauthConfig.ClientID == "" || oauthConfig.ClientSecret == "" { + http.Error(w, "OAuth not configured in connector. "+ + "Please configure client_id and client_secret in the connector settings.", http.StatusServiceUnavailable) + return + } + + // Resolve full redirect URL from current request + redirectURL := resolveRedirectURL(oauthConfig, req) + + // Build Box OAuth authorization URL + authURL := "https://account.box.com/api/oauth2/authorize" + params := url.Values{} + params.Set("client_id", oauthConfig.ClientID) + params.Set("response_type", "code") + params.Set("redirect_uri", redirectURL) + + fullAuthURL := fmt.Sprintf("%s?%s", authURL, params.Encode()) + + log.Infof("[box connector] Redirecting to Box OAuth: %s", fullAuthURL) + http.Redirect(w, req, fullAuthURL, http.StatusFound) +} + +// oAuthRedirect handles the OAuth callback from Box +func oAuthRedirect(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { + connectorID := ps.MustGetParameter("id") + + oauthConfig, err := getOAuthConfigFromConnector(connectorID) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to get OAuth config: %v", err), http.StatusInternalServerError) + return + } + + // Check if OAuth is properly configured in connector + if oauthConfig == nil || oauthConfig.ClientID == "" || oauthConfig.ClientSecret == "" { + http.Error(w, "OAuth not configured in connector. Please configure client_id and "+ + "client_secret in the connector settings.", http.StatusServiceUnavailable) + return + } + + // Resolve full redirect URL + oauthConfig.RedirectURL = resolveRedirectURL(oauthConfig, req) + + // Extract authorization code from query parameters + code := ps.ByName("code") + if code == "" { + // Try query parameter + code = req.URL.Query().Get("code") + } + if code == "" { + http.Error(w, "Missing authorization code.", http.StatusBadRequest) + return + } + + log.Debugf("[box connector] Received authorization code") + + // Exchange authorization code for tokens + // Box API requires application/x-www-form-urlencoded format + tokenURL := "https://api.box.com/oauth2/token" + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("code", code) + data.Set("client_id", oauthConfig.ClientID) + data.Set("client_secret", oauthConfig.ClientSecret) + data.Set("redirect_uri", oauthConfig.RedirectURL) + + resp, err := http.PostForm(tokenURL, data) + if err != nil { + _ = log.Errorf("[box connector] Failed to exchange code for token: %v", err) + http.Error(w, "Failed to exchange authorization code for token.", http.StatusInternalServerError) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + _ = log.Errorf("[box connector] Box token API error: status %d, body: %s", resp.StatusCode, string(body)) + http.Error(w, "Failed to exchange authorization code for token.", http.StatusInternalServerError) + return + } + + var tokenResp TokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + _ = log.Errorf("[box connector] Failed to decode token response: %v", err) + http.Error(w, "Failed to exchange authorization code for token.", http.StatusInternalServerError) + return + } + if tokenResp.AccessToken == "" || tokenResp.RefreshToken == "" { + _ = log.Error("[box connector] Received empty tokens from Box") + http.Error(w, "Received invalid tokens from Box", http.StatusInternalServerError) + return + } + + // Get user profile information + userProfile, err := getUserProfile(tokenResp.AccessToken) + if err != nil { + _ = log.Errorf("[box connector] Failed to get user profile: %v", err) + http.Error(w, "Failed to get user profile information.", http.StatusInternalServerError) + return + } + + log.Infof("[box connector] Successfully authenticated user: %v", userProfile) + + // Create datasource with OAuth tokens + datasource := core.DataSource{ + SyncConfig: core.SyncConfig{Enabled: true, Interval: "30s"}, + Enabled: true, + } + + // Generate unique datasource ID based on connector type and user info + userID := "" + if userProfile != nil && userProfile.ID != "" { + userID = userProfile.ID + } else if userProfile != nil && userProfile.Login != "" { + userID = userProfile.Login + } else { + userID = "unknown" + } + + datasource.ID = util.MD5digest(fmt.Sprintf("%v,%v", "box", userID)) + datasource.Type = "connector" + + // Set datasource name + if userProfile != nil && userProfile.Name != "" { + datasource.Name = fmt.Sprintf("%s's box", userProfile.Name) + } else { + datasource.Name = "My box" + } + + // Convert BoxUserProfile to map for storage + profile := util.MapStr{} + if userProfile != nil { + profile["id"] = userProfile.ID + profile["type"] = userProfile.Type + profile["name"] = userProfile.Name + profile["login"] = userProfile.Login + } + + // Create datasource config with OAuth tokens + datasource.Connector = core.ConnectorConfig{ + ConnectorID: connectorID, + Config: util.MapStr{ + "is_enterprise": AccountTypeFree, + "client_id": oauthConfig.ClientID, + "client_secret": oauthConfig.ClientSecret, + "access_token": tokenResp.AccessToken, + "refresh_token": tokenResp.RefreshToken, + "token_expiry": time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339), + "profile": profile, + }, + } + + // Check if refresh token is missing or empty + if tokenResp.RefreshToken == "" { + log.Warnf("refresh token was not granted for: %v", datasource.Name) + } + + // Verify the connection by creating and testing the client + log.Infof("[box connector] Verifying connection with obtained tokens...") + clientConfig := &Config{ + IsEnterprise: AccountTypeFree, + ClientID: oauthConfig.ClientID, + ClientSecret: oauthConfig.ClientSecret, + RefreshToken: tokenResp.RefreshToken, + } + + // Create client with the OAuth tokens we just obtained + client := NewBoxClientWithTokens(clientConfig, tokenResp.AccessToken, tokenResp.RefreshToken, tokenResp.ExpiresIn) + + // Test the connection + if err := client.Ping(); err != nil { + _ = log.Errorf("[box connector] Failed to verify connection: %v", err) + http.Error(w, fmt.Sprintf("Failed to verify connection: %v", err), http.StatusInternalServerError) + return + } + log.Infof("[box connector] Connection verified successfully") + + // Save datasource + ctx := orm.NewContextWithParent(req.Context()) + err = orm.Save(ctx, &datasource) + if err != nil { + _ = log.Errorf("[box connector] Failed to save datasource: %v", err) + http.Error(w, "Failed to save datasource.", http.StatusInternalServerError) + return + } + + log.Infof("[box connector] Successfully created datasource: %s", datasource.ID) + + // Cache the authenticated client for future use + CacheClient(datasource.ID, client) + + // Redirect to datasource detail page + newRedirectURL := fmt.Sprintf("/#/data-source/detail/%v", datasource.ID) + http.Redirect(w, req, newRedirectURL, http.StatusTemporaryRedirect) +} + +// BoxUserProfile represents Box user profile information +type BoxUserProfile struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Login string `json:"login"` +} + +// getUserProfile retrieves the current user's profile from Box +func getUserProfile(accessToken string) (*BoxUserProfile, error) { + req, err := http.NewRequest("GET", "https://api.box.com/2.0/users/me", nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("get user profile failed with status %d: %s", resp.StatusCode, string(body)) + } + + var profile BoxUserProfile + if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil { + return nil, fmt.Errorf("failed to decode profile response: %w", err) + } + + return &profile, nil +} diff --git a/plugins/connectors/box/client.go b/plugins/connectors/box/client.go new file mode 100644 index 000000000..69036d758 --- /dev/null +++ b/plugins/connectors/box/client.go @@ -0,0 +1,423 @@ +/* Copyright © INFINI LTD. All rights reserved. + * Web: https://infinilabs.com + * Email: hello#infini.ltd */ + +package box + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + log "github.com/cihub/seelog" +) + +const ( + BaseURL = "https://api.box.com" + TokenEndpoint = "/oauth2/token" + PingEndpoint = "/2.0/users/me" + FolderEndpoint = "/2.0/folders/%s/items" + UsersEndpoint = "/2.0/users" + DefaultTimeout = 30 * time.Second + TokenExpiryBuffer = 5 * time.Minute + DefaultPageSize = 100 +) + +const ( + AccountTypeFree = "box_free" + AccountTypeEnterprise = "box_enterprise" +) + +const ( + FileTypeFile = "file" + FileTypeFolder = "folder" +) + +// BoxFile represents a file or folder item from Box API +type BoxFile struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + ItemSize int64 `json:"item_size"` + Description string `json:"description"` + Date time.Time `json:"date"` + URL string `json:"url"` + LastUpdatedByName string `json:"last_updated_by_name"` + Extension string `json:"extension"` + ParentFolderID string `json:"parentFolderID"` + ParentFolderName string `json:"parentFolderName"` +} + +// FolderItemsResponse represents the response from Box folder items API +type FolderItemsResponse struct { + TotalCount int `json:"total_count"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Entries []*BoxFile `json:"entries"` +} + +// TokenCache manages access token caching with expiration +type TokenCache struct { + mu sync.RWMutex + accessToken string + refreshToken string + expiry time.Time +} + +func (tc *TokenCache) Get() (string, bool) { + tc.mu.RLock() + defer tc.mu.RUnlock() + + if tc.accessToken == "" || time.Now().After(tc.expiry.Add(-TokenExpiryBuffer)) { + return "", false + } + return tc.accessToken, true +} + +func (tc *TokenCache) Set(accessToken, refreshToken string, expiresIn int64) { + tc.mu.Lock() + defer tc.mu.Unlock() + + tc.accessToken = accessToken + if refreshToken != "" { + tc.refreshToken = refreshToken + } + tc.expiry = time.Now().Add(time.Duration(expiresIn) * time.Second) +} + +func (tc *TokenCache) GetRefreshToken() string { + tc.mu.RLock() + defer tc.mu.RUnlock() + return tc.refreshToken +} + +func (tc *TokenCache) SetRefreshToken(refreshToken string) { + tc.mu.Lock() + defer tc.mu.Unlock() + tc.refreshToken = refreshToken +} + +// BoxClient handles communication with the Box API +type BoxClient struct { + config *Config + httpClient *http.Client + tokenCache *TokenCache + baseURL string +} + +// NewBoxClient creates a new Box client +func NewBoxClient(config *Config) *BoxClient { + // Initialize token cache with refresh token for Free accounts + tokenCache := &TokenCache{} + if config.IsEnterprise == AccountTypeFree && config.RefreshToken != "" { + tokenCache.SetRefreshToken(config.RefreshToken) + } + + return &BoxClient{ + config: config, + httpClient: &http.Client{ + Timeout: DefaultTimeout, + }, + tokenCache: tokenCache, + baseURL: BaseURL, + } +} + +// NewBoxClientWithTokens creates a new Box client with pre-obtained OAuth tokens +// This is useful when creating a client right after OAuth authentication +func NewBoxClientWithTokens(config *Config, accessToken, refreshToken string, expiresIn int64) *BoxClient { + tokenCache := &TokenCache{} + + // Set both access token and refresh token + tokenCache.Set(accessToken, refreshToken, expiresIn) + + return &BoxClient{ + config: config, + httpClient: &http.Client{ + Timeout: DefaultTimeout, + }, + tokenCache: tokenCache, + baseURL: BaseURL, + } +} + +// TokenResponse represents the Box OAuth token response +type TokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresIn int64 `json:"expires_in"` + TokenType string `json:"token_type"` +} + +// Authenticate authenticates with Box and retrieves an access token +func (c *BoxClient) Authenticate() error { + log.Debugf("[box client] Generating an access token for account type: %s", c.config.IsEnterprise) + + var data url.Values + + if c.config.IsEnterprise == AccountTypeFree { + // Box Free Account: use refresh_token grant + refreshToken := c.tokenCache.GetRefreshToken() + if refreshToken == "" { + return fmt.Errorf("refresh_token is required for Box Free Account") + } + + data = url.Values{} + data.Set("grant_type", "refresh_token") + data.Set("refresh_token", refreshToken) + data.Set("client_id", c.config.ClientID) + data.Set("client_secret", c.config.ClientSecret) + } else { + // Box Enterprise Account: use client_credentials grant + if c.config.EnterpriseID == "" { + return fmt.Errorf("enterprise_id is required for Box Enterprise Account") + } + + data = url.Values{} + data.Set("grant_type", "client_credentials") + data.Set("client_id", c.config.ClientID) + data.Set("client_secret", c.config.ClientSecret) + data.Set("box_subject_type", "enterprise") + data.Set("box_subject_id", c.config.EnterpriseID) + } + + tokenURL := c.baseURL + TokenEndpoint + req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode())) + if err != nil { + return fmt.Errorf("failed to create token request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to execute token request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read token response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("token request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var tokenResp TokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return fmt.Errorf("failed to decode token response: %w", err) + } + + // Cache the token + c.tokenCache.Set(tokenResp.AccessToken, tokenResp.RefreshToken, tokenResp.ExpiresIn) + + // Log token info + if c.config.IsEnterprise == AccountTypeFree { + if tokenResp.RefreshToken != "" { + log.Debugf("[box client] Successfully authenticated (Free Account), token expires in %d seconds, refresh_token updated", tokenResp.ExpiresIn) + } else { + log.Debugf("[box client] Successfully authenticated (Free Account), token expires in %d seconds", tokenResp.ExpiresIn) + } + } else { + log.Debugf("[box client] Successfully authenticated (Enterprise Account), token expires in %d seconds", tokenResp.ExpiresIn) + } + + return nil +} + +// GetAccessToken returns a valid access token, refreshing if necessary +func (c *BoxClient) GetAccessToken() (string, error) { + // Try to get cached token + if token, valid := c.tokenCache.Get(); valid { + return token, nil + } + + // Token expired or not found, authenticate + log.Debug("[box client] No valid token cache found; fetching new token") + if err := c.Authenticate(); err != nil { + return "", err + } + + // Get the newly cached token + token, _ := c.tokenCache.Get() + return token, nil +} + +// Ping tests the connection to Box +func (c *BoxClient) Ping() error { + resp, err := c.Get(PingEndpoint, nil) + if err != nil { + return fmt.Errorf("failed to execute ping request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("ping failed with status %d: %s", resp.StatusCode, string(body)) + } + + log.Debug("[box client] Successfully pinged Box API") + return nil +} + +// Get makes an authenticated GET request to the Box API +func (c *BoxClient) Get(endpoint string, params url.Values) (*http.Response, error) { + return c.GetWithHeaders(endpoint, params, nil) +} + +// GetWithHeaders makes an authenticated GET request to the Box API with custom headers +// Additional headers can be provided (e.g., "as-user" for enterprise accounts) +func (c *BoxClient) GetWithHeaders(endpoint string, params url.Values, headers map[string]string) (*http.Response, error) { + accessToken, err := c.GetAccessToken() + if err != nil { + return nil, err + } + + requestURL := c.baseURL + endpoint + if params != nil && len(params) > 0 { + requestURL = requestURL + "?" + params.Encode() + } + + req, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + req.Header.Set("Accept", "application/json") + + // Set additional headers + for key, value := range headers { + req.Header.Set(key, value) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + + // Handle 401 Unauthorized - token might be expired + if resp.StatusCode == http.StatusUnauthorized { + resp.Body.Close() + + // Force re-authentication + c.tokenCache.accessToken = "" + log.Warn("[box client] Received 401, re-authenticating...") + + if err := c.Authenticate(); err != nil { + return nil, fmt.Errorf("re-authentication failed: %w", err) + } + + // Retry the request with new token + accessToken, _ = c.tokenCache.Get() + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + + resp, err = c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to retry request: %w", err) + } + } + + return resp, nil +} + +// GetFolderItems retrieves items in a folder with pagination +// For enterprise accounts, userID should be provided to fetch items as that user +func (c *BoxClient) GetFolderItems(folderID string, offset, limit int, userID string) (*FolderItemsResponse, error) { + endpoint := fmt.Sprintf(FolderEndpoint, folderID) + + params := url.Values{} + params.Set("offset", fmt.Sprintf("%d", offset)) + params.Set("limit", fmt.Sprintf("%d", limit)) + params.Set("fields", "id,type,name,item_size,description,date,url,last_updated_by_name,parent,extension,parent_folder_id,parent_folder_name") + + // Prepare headers for enterprise accounts + var headers map[string]string + if userID != "" { + headers = map[string]string{"as-user": userID} + } + + resp, err := c.GetWithHeaders(endpoint, params, headers) + if err != nil { + return nil, fmt.Errorf("failed to get folder items: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("get folder items failed with status %d: %s", resp.StatusCode, string(body)) + } + + var itemsResp FolderItemsResponse + if err := json.NewDecoder(resp.Body).Decode(&itemsResp); err != nil { + return nil, fmt.Errorf("failed to decode folder items response: %w", err) + } + + return &itemsResp, nil +} + +// BoxUser represents a user from Box API +type BoxUser struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Login string `json:"login"` +} + +// UsersResponse represents the response from Box users API +type UsersResponse struct { + TotalCount int `json:"total_count"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Entries []BoxUser `json:"entries"` +} + +// GetUsers retrieves all users in the enterprise (Enterprise account only) +func (c *BoxClient) GetUsers() ([]BoxUser, error) { + if c.config.IsEnterprise != AccountTypeEnterprise { + return nil, fmt.Errorf("GetUsers is only available for Enterprise accounts") + } + + var allUsers []BoxUser + offset := 0 + limit := DefaultPageSize + + for { + params := url.Values{} + params.Set("offset", fmt.Sprintf("%d", offset)) + params.Set("limit", fmt.Sprintf("%d", limit)) + + resp, err := c.Get(UsersEndpoint, params) + if err != nil { + return nil, fmt.Errorf("failed to get users: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("get users failed with status %d: %s", resp.StatusCode, string(body)) + } + + var usersResp UsersResponse + if err := json.NewDecoder(resp.Body).Decode(&usersResp); err != nil { + return nil, fmt.Errorf("failed to decode users response: %w", err) + } + + allUsers = append(allUsers, usersResp.Entries...) + + // Check if we have more users + if offset+limit >= usersResp.TotalCount { + break + } + offset += limit + } + + log.Debugf("[box client] Retrieved %d users from enterprise", len(allUsers)) + return allUsers, nil +} diff --git a/plugins/connectors/box/files.go b/plugins/connectors/box/files.go new file mode 100644 index 000000000..0f0cadccc --- /dev/null +++ b/plugins/connectors/box/files.go @@ -0,0 +1,353 @@ +/* Copyright © INFINI LTD. All rights reserved. + * Web: https://infinilabs.com + * Email: hello#infini.ltd */ + +package box + +import ( + "fmt" + "strings" + "time" + + "infini.sh/coco/core" + "infini.sh/coco/modules/common" + "infini.sh/framework/core/pipeline" + "infini.sh/framework/core/util" + + log "github.com/cihub/seelog" +) + +type FolderNode struct { + ID string + Name string + ParentID string + ParentPath string + ParentPathArray []string + Size int64 + Type string + URL string + UserID string // For enterprise accounts with multiple users +} + +func (processor *Processor) startIndexingFiles( + ctx *pipeline.Context, + connector *core.Connector, + datasource *core.DataSource, + client *BoxClient, +) { + processor.startIndexingFilesForUser(ctx, connector, datasource, client, "", "") +} + +func (processor *Processor) startIndexingFilesForUser( + ctx *pipeline.Context, + connector *core.Connector, + datasource *core.DataSource, + client *BoxClient, + userID, userName string, +) { + // Get root folder ID (0 for Box) + rootFolderID := "0" + + // For enterprise accounts with multiple users, create a user-specific root category + // For free accounts, use standard root "/" + var rootPath string + var pathArray []string + + if userID != "" { + // Enterprise account: create user-specific hierarchy with user name as top category + // This ensures different users' files are properly separated in the hierarchy + rootPath = "/" + if userName != "" { + pathArray = []string{userName} + } else { + pathArray = []string{userID} + } + } else { + // Free account: standard root without user prefix + rootPath = "/" + pathArray = []string{} + } + + rootFolder := &FolderNode{ + ID: rootFolderID, + Name: "", + ParentID: "", + ParentPath: rootPath, + ParentPathArray: pathArray, + Size: 0, + Type: FileTypeFolder, + URL: getUrl(FileTypeFolder, "0"), + UserID: userID, + } + + // Process files recursively starting from root + processor.processFolderRecursively(ctx, connector, datasource, client, rootFolder) +} + +func (processor *Processor) processFolderRecursively( + ctx *pipeline.Context, + connector *core.Connector, + datasource *core.DataSource, + client *BoxClient, + folder *FolderNode, +) { + log.Debugf("Processing folder: %s (ID: %s)", folder.Name, folder.ID) + + // Skip creating document for root folder (/) + if folder.Name != "" { + // Create folder directory document + folderDoc := common.CreateHierarchyPathFolderDoc( + datasource, + folder.ID, + folder.Name, + folder.ParentPathArray, + ) + folderDoc.URL = getUrl(folder.Type, folder.ID) + folderDoc.Metadata = util.MapStr{ + "folder_type": FileTypeFolder, + "folder_id": folder.ID, + "platform": NAME, + "size": folder.Size, + } + + // Collect folder document + processor.Collect(ctx, connector, datasource, folderDoc) + } + + // Get folder items + offset := 0 + limit := DefaultPageSize + + for { + // Pass userID for enterprise accounts (as-user header) + items, err := client.GetFolderItems(folder.ID, offset, limit, folder.UserID) + if err != nil { + log.Errorf("Failed to get folder items for %s: %v", folder.ID, err) + break + } + + // Process each item + for _, item := range items.Entries { + processor.processItem(ctx, connector, datasource, item, client, folder) + } + + // Check if we have more items + if offset+limit >= items.TotalCount { + break + } + offset += limit + } +} + +func (processor *Processor) processItem( + ctx *pipeline.Context, + connector *core.Connector, + datasource *core.DataSource, + item *BoxFile, + client *BoxClient, + parentFolder *FolderNode, +) { + if item.Type == FileTypeFolder { + // Compute child folder path + var childPath string + if parentFolder.ParentPath == "" || parentFolder.ParentPath == "/" { + childPath = "/" + item.Name + } else { + childPath = parentFolder.ParentPath + "/" + item.Name + } + + // Process folder + childFolder := &FolderNode{ + ID: item.ID, + Name: item.Name, + ParentID: parentFolder.ID, + ParentPath: childPath, + ParentPathArray: append(parentFolder.ParentPathArray, item.Name), + Size: item.ItemSize, + Type: item.Type, + URL: getUrl(item.Type, item.ID), + UserID: parentFolder.UserID, // Propagate UserID + } + + // Recursively process the folder + processor.processFolderRecursively(ctx, connector, datasource, client, childFolder) + } else if item.Type == FileTypeFile { + // Process file + processor.processFile(ctx, connector, datasource, item, parentFolder) + } +} + +func (processor *Processor) processFile( + ctx *pipeline.Context, + connector *core.Connector, + datasource *core.DataSource, + file *BoxFile, + parentFolder *FolderNode, +) { + // Create document + doc := core.Document{ + Source: core.DataSourceReference{ + ID: datasource.ID, + Type: "connector", + Name: datasource.Name, + }, + } + + doc.System = datasource.System + doc.Title = file.Name + doc.Type = file.Type + doc.Icon = getIconTypeFromExtension(file.Extension) + doc.URL = getUrl(file.Type, file.ID) + + // Set hierarchy path + doc.Category = common.GetFullPathForCategories(parentFolder.ParentPathArray) + doc.Categories = parentFolder.ParentPathArray + + if doc.System == nil { + doc.System = util.MapStr{} + } + doc.System[common.SystemHierarchyPathKey] = doc.Category + + // Set timestamps + if !file.Date.IsZero() { + doc.Created = &file.Date + doc.Updated = &file.Date + } else { + now := time.Now() + doc.Created = &now + doc.Updated = &now + } + + // Set content - use description if available + doc.Content = file.Description + + // Set summary - use description, truncate if too long + if file.Description != "" { + if len(file.Description) > 200 { + doc.Summary = file.Description[:200] + "..." + } else { + doc.Summary = file.Description + } + } else { + doc.Summary = file.Name + } + + // Set subcategory - use file extension or type + if file.Extension != "" { + doc.Subcategory = strings.ToUpper(file.Extension) + } else { + doc.Subcategory = file.Type + } + + // Set tags - include file extension, status, and type + var tags []string + if file.Extension != "" { + tags = append(tags, "ext:"+strings.ToLower(file.Extension)) + } + if file.Type != "" { + tags = append(tags, file.Type) + } + doc.Tags = tags + doc.Owner = &core.UserInfo{UserName: file.LastUpdatedByName} + + // Set metadata + doc.Metadata = util.MapStr{ + "id": file.ID, + "type": file.Type, + "item_size": file.ItemSize, + "description": file.Description, + "platform": NAME, + } + + // Add user_id for enterprise accounts + if parentFolder.UserID != "" { + doc.Metadata["user_id"] = parentFolder.UserID + } + doc.Payload = map[string]interface{}{ + "id": file.ID, + "name": file.Name, + "type": file.Type, + "item_size": file.ItemSize, + } + + // Generate document ID + // For enterprise accounts, include userID to avoid conflicts between users + if parentFolder.UserID != "" { + doc.ID = util.MD5digest(fmt.Sprintf("%v-%v-%v", datasource.ID, parentFolder.UserID, file.ID)) + } else { + doc.ID = util.MD5digest(fmt.Sprintf("%v-%v", datasource.ID, file.ID)) + } + + // Collect document + // Note: File content extraction is handled by the coco-server framework's + // document processing pipeline, not in the connector itself + processor.Collect(ctx, connector, datasource, doc) +} + +// getIconTypeFromExtension returns the icon type based on file extension (without dot) +func getIconTypeFromExtension(ext string) string { + ext = strings.ToLower(ext) + + // Map extensions to Box icon types + switch ext { + // PDF + case "pdf": + return "pdf" + + // Microsoft Office - Word + case "doc", "docx", "docm", "dot", "dotx", "dotm": + return "docx" + + // Microsoft Office - Excel + case "xls", "xlsx", "xlsm", "xlsb", "xlt", "xltx", "xltm": + return "excel-spreadsheet" + + // Microsoft Office - PowerPoint + case "ppt", "pptx", "pptm", "pot", "potx", "potm", "pps", "ppsx", "ppsm": + return "powerpoint-presentation" + + // Apple iWork - Pages + case "pages": + return "pages" + + // Apple iWork - Numbers + case "numbers": + return "numbers" + + // Apple iWork - Keynote + case "keynote", "key": + return "keynote" + + // Google Docs (if saved with these extensions) + case "gdoc": + return "google-docs" + + // Google Sheets + case "gsheet": + return "google-sheets" + + // Google Slides + case "gslides": + return "google-slides" + + // Box specific formats + case "boxnote": + return "boxnote" + + // Box Canvas + case "boxcanvas": + return "boxcanvas" + + // Bookmark + case "url", "webloc", "website": + return "bookmark" + + // Default + default: + return "default" + } +} + +func getUrl(docType, docId string) string { + return fmt.Sprintf("https://app.box.com/%s/%s", docType, docId) +} diff --git a/plugins/connectors/box/plugin.go b/plugins/connectors/box/plugin.go new file mode 100644 index 000000000..b2b5797cd --- /dev/null +++ b/plugins/connectors/box/plugin.go @@ -0,0 +1,140 @@ +/* Copyright © INFINI LTD. All rights reserved. + * Web: https://infinilabs.com + * Email: hello#infini.ltd */ + +package box + +import ( + "fmt" + + "infini.sh/coco/core" + cmn "infini.sh/coco/plugins/connectors/common" + "infini.sh/framework/core/api" + "infini.sh/framework/core/config" + "infini.sh/framework/core/pipeline" + + log "github.com/cihub/seelog" +) + +const NAME = "box" + +type Config struct { + // Account type: "box_free" or "box_enterprise" + IsEnterprise string `config:"is_enterprise" json:"is_enterprise"` + + // OAuth credentials (required for both account types) + ClientID string `config:"client_id" json:"client_id"` + ClientSecret string `config:"client_secret" json:"client_secret"` + + // For Box Free Account only + RefreshToken string `config:"refresh_token" json:"refresh_token"` + + // For Box Enterprise Account only + EnterpriseID string `config:"enterprise_id" json:"enterprise_id"` + + // Optional settings + ConcurrentDownloads int `config:"concurrent_downloads" json:"concurrent_downloads"` +} + +type Processor struct { + cmn.ConnectorProcessorBase +} + +func init() { + pipeline.RegisterProcessorPlugin(NAME, New) + + // Register OAuth routes for Box Free Account + api.HandleUIMethod(api.GET, "/connector/:id/box/connect", connect, api.RequireLogin()) + api.HandleUIMethod(api.GET, "/connector/:id/box/oauth_redirect", oAuthRedirect, api.RequireLogin()) +} + +func New(c *config.Config) (pipeline.Processor, error) { + runner := Processor{} + runner.Init(c, &runner) + return &runner, nil +} + +func (processor *Processor) Name() string { + return NAME +} + +func (processor *Processor) Fetch(pipeCtx *pipeline.Context, connector *core.Connector, datasource *core.DataSource) error { + cfg := Config{} + processor.MustParseConfig(datasource, &cfg) + + log.Debugf("[%s connector] handling datasource: %s", NAME, datasource.Name) + + // Validate required configuration + if cfg.ClientID == "" { + return fmt.Errorf("client_id is required for Box connector") + } + if cfg.ClientSecret == "" { + return fmt.Errorf("client_secret is required for Box connector") + } + + // Validate account type specific configuration + if cfg.IsEnterprise == AccountTypeFree { + if cfg.RefreshToken == "" { + return fmt.Errorf("refresh_token is required for Box Free Account") + } + } else if cfg.IsEnterprise == AccountTypeEnterprise { + if cfg.EnterpriseID == "" { + return fmt.Errorf("enterprise_id is required for Box Enterprise Account") + } + } else { + // Default to free account if not specified + cfg.IsEnterprise = AccountTypeFree + if cfg.RefreshToken == "" { + return fmt.Errorf("refresh_token is required for Box Free Account") + } + } + + // Try to get client from cache first + client, cached := GetCachedClient(datasource.ID) + + if cached { + log.Debugf("[%s connector] Using cached client for datasource: %s", NAME, datasource.ID) + } else { + // No cached client, create a new one + log.Debugf("[%s connector] No cached client found, creating new client for datasource: %s", NAME, datasource.ID) + client = NewBoxClient(&cfg) + + // Test connection for new client + log.Debugf("[%s connector] testing connection...", NAME) + if err := client.Ping(); err != nil { + return fmt.Errorf("failed to connect to Box: %v", err) + } + log.Debugf("[%s connector] connection test successful", NAME) + + // Cache the new client for future use + CacheClient(datasource.ID, client) + } + + // Start processing files + log.Debugf("[%s connector] start processing box files for datasource: %s", NAME, datasource.Name) + + if cfg.IsEnterprise == AccountTypeEnterprise { + // Enterprise account: fetch files for all users + log.Infof("[%s connector] Fetching data from Box's Enterprise Account", NAME) + + users, err := client.GetUsers() + if err != nil { + return fmt.Errorf("failed to get enterprise users: %v", err) + } + + log.Infof("[%s connector] Found %d users in enterprise", NAME, len(users)) + + for _, user := range users { + log.Debugf("[%s connector] Processing files for user: %s (%s)", NAME, user.Name, user.ID) + processor.startIndexingFilesForUser(pipeCtx, connector, datasource, client, user.ID, user.Name) + } + } else { + // Free account: fetch files for current authenticated user + log.Infof("[%s connector] Fetching data from Box's Free Account", NAME) + processor.startIndexingFiles(pipeCtx, connector, datasource, client) + } + + log.Infof("[%s connector] finished fetching datasource [%s]", NAME, datasource.Name) + + return nil +} diff --git a/plugins/connectors/feishu/api.go b/plugins/connectors/feishu/api.go index 5ba42aa99..ea41ac7e3 100644 --- a/plugins/connectors/feishu/api.go +++ b/plugins/connectors/feishu/api.go @@ -3,6 +3,8 @@ package feishu import ( "fmt" "infini.sh/coco/core" + "infini.sh/coco/modules/connector" + "infini.sh/coco/plugins/connectors" "net/http" "net/url" "strings" @@ -210,24 +212,21 @@ func getOAuthConfigFromConnector(connectorID string, pluginType PluginType) (*OA RedirectURL: fmt.Sprintf("/connector/%s/%s/oauth_redirect", connectorID, pluginType), } - // Try to load connector to get OAuth credentials - connector := core.Connector{} - connector.ID = connectorID - exists, err := orm.Get(&connector) - if err == nil && exists && connector.Config != nil { - if clientID, ok := connector.Config["client_id"].(string); ok { - oauthConfig.ClientID = clientID - } - if clientSecret, ok := connector.Config["client_secret"].(string); ok { - oauthConfig.ClientSecret = clientSecret - } - if authURL, ok := connector.Config["auth_url"].(string); ok { - oauthConfig.AuthURL = authURL - } - if tokenURL, ok := connector.Config["token_url"].(string); ok { - oauthConfig.TokenURL = tokenURL - } + if connectorID == "" { + return nil, fmt.Errorf("connector id is empty") + } + cfg, err := connector.GetConnectorByID(connectorID) + if err != nil || cfg == nil { + return nil, fmt.Errorf("invalid connector config") + } + err = connectors.ParseConnectorBaseConfigure(cfg, &oauthConfig) + if err != nil { + return nil, fmt.Errorf("invalid oauth config parse from connector") + } + if oauthConfig.ClientID == "" || oauthConfig.ClientSecret == "" || len(oauthConfig.RedirectURL) == 0 { + return nil, fmt.Errorf("missing %s OAuth credentials", pluginType) } + log.Infof("oauthConfig: %v", oauthConfig) return oauthConfig, nil } diff --git a/web/public/assets/icons/connector/box/bookmark.png b/web/public/assets/icons/connector/box/bookmark.png new file mode 100644 index 000000000..bff814d4c Binary files /dev/null and b/web/public/assets/icons/connector/box/bookmark.png differ diff --git a/web/public/assets/icons/connector/box/boxcanvas.png b/web/public/assets/icons/connector/box/boxcanvas.png new file mode 100644 index 000000000..ae3196cf5 Binary files /dev/null and b/web/public/assets/icons/connector/box/boxcanvas.png differ diff --git a/web/public/assets/icons/connector/box/boxnote.png b/web/public/assets/icons/connector/box/boxnote.png new file mode 100644 index 000000000..c5a7be7c5 Binary files /dev/null and b/web/public/assets/icons/connector/box/boxnote.png differ diff --git a/web/public/assets/icons/connector/box/docx.png b/web/public/assets/icons/connector/box/docx.png new file mode 100644 index 000000000..fcb530458 Binary files /dev/null and b/web/public/assets/icons/connector/box/docx.png differ diff --git a/web/public/assets/icons/connector/box/excel-spreadsheet.png b/web/public/assets/icons/connector/box/excel-spreadsheet.png new file mode 100644 index 000000000..338f6aa7a Binary files /dev/null and b/web/public/assets/icons/connector/box/excel-spreadsheet.png differ diff --git a/web/public/assets/icons/connector/box/google-docs.png b/web/public/assets/icons/connector/box/google-docs.png new file mode 100644 index 000000000..dfad8feb5 Binary files /dev/null and b/web/public/assets/icons/connector/box/google-docs.png differ diff --git a/web/public/assets/icons/connector/box/google-sheets.png b/web/public/assets/icons/connector/box/google-sheets.png new file mode 100644 index 000000000..e634578c5 Binary files /dev/null and b/web/public/assets/icons/connector/box/google-sheets.png differ diff --git a/web/public/assets/icons/connector/box/google-slides.png b/web/public/assets/icons/connector/box/google-slides.png new file mode 100644 index 000000000..9c3d99bfb Binary files /dev/null and b/web/public/assets/icons/connector/box/google-slides.png differ diff --git a/web/public/assets/icons/connector/box/icon.png b/web/public/assets/icons/connector/box/icon.png new file mode 100644 index 000000000..138786818 Binary files /dev/null and b/web/public/assets/icons/connector/box/icon.png differ diff --git a/web/public/assets/icons/connector/box/keynote.png b/web/public/assets/icons/connector/box/keynote.png new file mode 100644 index 000000000..74c5977fe Binary files /dev/null and b/web/public/assets/icons/connector/box/keynote.png differ diff --git a/web/public/assets/icons/connector/box/numbers.png b/web/public/assets/icons/connector/box/numbers.png new file mode 100644 index 000000000..8c72a7cf1 Binary files /dev/null and b/web/public/assets/icons/connector/box/numbers.png differ diff --git a/web/public/assets/icons/connector/box/pages.png b/web/public/assets/icons/connector/box/pages.png new file mode 100644 index 000000000..0052cc2fa Binary files /dev/null and b/web/public/assets/icons/connector/box/pages.png differ diff --git a/web/public/assets/icons/connector/box/pdf.png b/web/public/assets/icons/connector/box/pdf.png new file mode 100644 index 000000000..039bea345 Binary files /dev/null and b/web/public/assets/icons/connector/box/pdf.png differ diff --git a/web/public/assets/icons/connector/box/powerpoint-presentation.png b/web/public/assets/icons/connector/box/powerpoint-presentation.png new file mode 100644 index 000000000..88f961fa5 Binary files /dev/null and b/web/public/assets/icons/connector/box/powerpoint-presentation.png differ diff --git a/web/src/components/datasource/type/index.jsx b/web/src/components/datasource/type/index.jsx index f8471bed1..98f2959f3 100644 --- a/web/src/components/datasource/type/index.jsx +++ b/web/src/components/datasource/type/index.jsx @@ -28,7 +28,8 @@ export const Types = { Postgresql: 'postgresql', RSS: 'rss', S3: 's3', - Yuque: 'yuque' + Yuque: 'yuque', + Box: 'box' }; export const TypeList = ({ diff --git a/web/src/locales/langs/en-us/page.ts b/web/src/locales/langs/en-us/page.ts index a289bf0ac..172e2e6d7 100644 --- a/web/src/locales/langs/en-us/page.ts +++ b/web/src/locales/langs/en-us/page.ts @@ -193,7 +193,7 @@ const page: App.I18n.Schema['translation']['page'] = { }, connect: 'Connect', missing_config_tip: - 'Google authorization parameters are not configured. Please set them before connecting. Click Confirm to go to the settings page.', + '{{name}} authorization parameters are not configured. Please set them before connecting. Click Confirm to go to the settings page.', delete: { confirm: 'Are you sure you want to delete this datasource?' }, diff --git a/web/src/locales/langs/zh-cn/page.ts b/web/src/locales/langs/zh-cn/page.ts index 91d26164b..4e0d8ff3a 100644 --- a/web/src/locales/langs/zh-cn/page.ts +++ b/web/src/locales/langs/zh-cn/page.ts @@ -249,7 +249,7 @@ const page: App.I18n.Schema['translation']['page'] = { }, hours: '小时', minutes: '分钟', - missing_config_tip: 'Google 授权相关参数没有设置,需设置后才能连接,点击确认跳转到设置页面。', + missing_config_tip: '{{name}} 授权相关参数没有设置,需设置后才能连接,点击确认跳转到设置页面。', mongodb: { error: { collection_required: '请输入集合名称!', diff --git a/web/src/pages/data-source/modules/OauthConnect.tsx b/web/src/pages/data-source/modules/OauthConnect.tsx index 1858aff6a..2d9f8f1ae 100644 --- a/web/src/pages/data-source/modules/OauthConnect.tsx +++ b/web/src/pages/data-source/modules/OauthConnect.tsx @@ -23,30 +23,30 @@ export const OAuthValidationPresets = { { field: 'redirect_url', required: true, label: 'Redirect URL' }, { field: 'token_url', required: true, label: 'Token URL' }, ], - + // Minimal OAuth (3 fields) - Some providers might not need redirect_url/token_url minimal: [ { field: 'auth_url', required: true, label: 'Authorization URL' }, { field: 'client_id', required: true, label: 'Client ID' }, { field: 'client_secret', required: true, label: 'Client Secret' }, ], - + // Backend-only validation (1 field) - Just check auth_url to enable OAuth UI backendOnly: [ { field: 'auth_url', required: true, label: 'Authorization URL' }, ], - + // Credentials only (2 fields) - When endpoints are hardcoded in backend credentialsOnly: [ { field: 'client_id', required: true, label: 'Client ID' }, { field: 'client_secret', required: true, label: 'Client Secret' }, ], - + // Authorization only (1 field) - Just need auth endpoint authOnly: [ { field: 'auth_url', required: true, label: 'Authorization URL' }, ], - + // Custom validation examples for specific providers googleDrive: [ { field: 'auth_url', required: true, label: 'Authorization URL' }, @@ -55,40 +55,40 @@ export const OAuthValidationPresets = { { field: 'redirect_url', required: true, label: 'Redirect URL' }, { field: 'token_url', required: true, label: 'Token URL' }, ], - + feishuLark: [ { field: 'client_id', required: true, label: 'Client ID' }, { field: 'client_secret', required: true, label: 'Client Secret' }, ], - + // Example of conditional validation - require redirect_url only if not using default conditionalExample: [ { field: 'auth_url', required: true, label: 'Authorization URL' }, { field: 'client_id', required: true, label: 'Client ID' }, { field: 'client_secret', required: true, label: 'Client Secret' }, - { - field: 'redirect_url', + { + field: 'redirect_url', required: (config: OAuthConfig) => { // Only require redirect_url if auth_url is provided (conditional logic) return !!config.auth_url; - }, - label: 'Redirect URL' + }, + label: 'Redirect URL' }, { field: 'token_url', required: true, label: 'Token URL' }, ], - + // Example with custom validator - validate URL format withCustomValidation: [ { field: 'auth_url', required: true, label: 'Authorization URL' }, { field: 'client_id', required: true, label: 'Client ID' }, { field: 'client_secret', required: true, label: 'Client Secret' }, - { - field: 'redirect_url', - required: true, + { + field: 'redirect_url', + required: true, label: 'Redirect URL', customValidator: (config: OAuthConfig) => { if (!config.redirect_url) return { valid: false, message: 'Redirect URL is required' }; - + // Basic URL validation try { new URL(config.redirect_url); @@ -112,6 +112,7 @@ interface OAuthConnectProps { connector: { id?: string; config?: OAuthConfig; + name?: string; }; connectUrl?: string; missingConfigMessage?: string; @@ -120,8 +121,8 @@ interface OAuthConnectProps { validationRules?: ValidationRule[]; // Configurable validation rules } -export default function OAuthConnect({ - connector, +export default function OAuthConnect({ + connector, connectUrl, missingConfigMessage, connectButtonText, @@ -151,11 +152,11 @@ export default function OAuthConnect({ const missingFields: string[] = []; const missingFieldLabels: string[] = []; const customErrors: string[] = []; - + rules.forEach(rule => { // Check if field is required (handle conditional requirements) const isRequired = typeof rule.required === 'function' ? rule.required(config) : rule.required; - + if (isRequired) { // Run custom validator if provided if (rule.customValidator) { @@ -164,7 +165,7 @@ export default function OAuthConnect({ customErrors.push(result.message || `${rule.label || rule.field} is invalid`); } } - + // Check if required field is missing if (!config[rule.field]) { missingFields.push(rule.field); @@ -175,12 +176,12 @@ export default function OAuthConnect({ // Combine all errors const allErrors = [...missingFieldLabels, ...customErrors]; - + if (allErrors.length > 0) { // Build specific error message - const errorMessage = missingConfigMessage || - t('page.datasource.missing_config_tip') || - `OAuth configuration issues: ${allErrors.join(', ')}`; + const processorName = connector?.name; + const errorMessage = missingConfigMessage || + ( processorName ? t('page.datasource.missing_config_tip', { name: processorName }) : `OAuth configuration issues: ${allErrors.join(', ')}`) window?.$modal?.confirm({ title: t("common.tip"), @@ -208,4 +209,4 @@ export default function OAuthConnect({ ); -} \ No newline at end of file +} diff --git a/web/src/pages/data-source/new/index.tsx b/web/src/pages/data-source/new/index.tsx index da9c0b9ee..7db258a5f 100644 --- a/web/src/pages/data-source/new/index.tsx +++ b/web/src/pages/data-source/new/index.tsx @@ -82,6 +82,7 @@ export function Component() { case Types.Feishu: case Types.Lark: + case Types.Box: // Feishu/Lark have hardcoded endpoints in backend, only need credentials return OAuthValidationPresets.feishuLark; @@ -137,6 +138,8 @@ export function Component() { return 'Feishu'; case Types.Lark: return 'Lark'; + case Types.Box: + return 'Box'; default: return connector?.id || type || 'Unknown'; } @@ -215,7 +218,8 @@ export function Component() { Types.Neo4j, Types.MongoDB, Types.Feishu, - Types.Lark + Types.Lark, + Types.Box ].includes(type); const connectorTypeName = getConnectorTypeName(); @@ -423,7 +427,9 @@ export function Component() { ? `/connector/${connector?.id}/feishu/connect` : type === Types.Lark ? `/connector/${connector?.id}/lark/connect` - : undefined + : type === Types.Box + ? `/connector/${connector?.id}/box/connect` + : undefined } /> ) : (