From 26c073bd5f4e1781febcaecf4a44634f027faf65 Mon Sep 17 00:00:00 2001 From: Kris Hansen Date: Sat, 14 Dec 2024 19:20:15 -0700 Subject: [PATCH] feat: server parity --- docs/comanda-api.postman_collection.json | 363 ++++++++++++++++ docs/openapi.yaml | 527 +++++++++++++++++++++++ docs/server-api.md | 433 +++++++++++++++++++ utils/server/bulk_operations.go | 237 ++++++++++ utils/server/bulk_operations_test.go | 284 ++++++++++++ utils/server/env_handlers.go | 148 +++++++ utils/server/env_handlers_test.go | 230 ++++++++++ utils/server/file_backup.go | 274 ++++++++++++ utils/server/file_backup_test.go | 289 +++++++++++++ utils/server/file_handlers.go | 353 +++++++++++++++ utils/server/file_handlers_test.go | 348 +++++++++++++++ utils/server/handlers.go | 2 +- utils/server/handlers_test.go | 50 ++- utils/server/provider_handlers.go | 242 +++++++++++ utils/server/server.go | 207 ++++++--- utils/server/types.go | 124 +++++- 16 files changed, 4004 insertions(+), 107 deletions(-) create mode 100644 docs/comanda-api.postman_collection.json create mode 100644 docs/openapi.yaml create mode 100644 docs/server-api.md create mode 100644 utils/server/bulk_operations.go create mode 100644 utils/server/bulk_operations_test.go create mode 100644 utils/server/env_handlers.go create mode 100644 utils/server/env_handlers_test.go create mode 100644 utils/server/file_backup.go create mode 100644 utils/server/file_backup_test.go create mode 100644 utils/server/file_handlers.go create mode 100644 utils/server/file_handlers_test.go create mode 100644 utils/server/provider_handlers.go diff --git a/docs/comanda-api.postman_collection.json b/docs/comanda-api.postman_collection.json new file mode 100644 index 0000000..90d858e --- /dev/null +++ b/docs/comanda-api.postman_collection.json @@ -0,0 +1,363 @@ +{ + "info": { + "name": "Comanda API", + "description": "Collection for testing Comanda server API endpoints", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "base_url", + "value": "http://localhost:8080", + "type": "string" + }, + { + "key": "bearer_token", + "value": "your-token-here", + "type": "string" + } + ], + "item": [ + { + "name": "Provider Management", + "item": [ + { + "name": "List Providers", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{bearer_token}}" + } + ], + "url": { + "raw": "{{base_url}}/providers", + "host": ["{{base_url}}"], + "path": ["providers"] + } + } + }, + { + "name": "Update Provider", + "request": { + "method": "PUT", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{bearer_token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"openai\",\n \"apiKey\": \"your-api-key\",\n \"models\": [\"gpt-4\", \"gpt-3.5-turbo\"],\n \"enabled\": true\n}" + }, + "url": { + "raw": "{{base_url}}/providers", + "host": ["{{base_url}}"], + "path": ["providers"] + } + } + }, + { + "name": "Delete Provider", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{bearer_token}}" + } + ], + "url": { + "raw": "{{base_url}}/providers/openai", + "host": ["{{base_url}}"], + "path": ["providers", "openai"] + } + } + } + ] + }, + { + "name": "Environment Security", + "item": [ + { + "name": "Encrypt Environment", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{bearer_token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"password\": \"your-password\"\n}" + }, + "url": { + "raw": "{{base_url}}/env/encrypt", + "host": ["{{base_url}}"], + "path": ["env", "encrypt"] + } + } + }, + { + "name": "Decrypt Environment", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{bearer_token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"password\": \"your-password\"\n}" + }, + "url": { + "raw": "{{base_url}}/env/decrypt", + "host": ["{{base_url}}"], + "path": ["env", "decrypt"] + } + } + } + ] + }, + { + "name": "File Operations", + "item": [ + { + "name": "List Files", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{bearer_token}}" + } + ], + "url": { + "raw": "{{base_url}}/list", + "host": ["{{base_url}}"], + "path": ["list"] + } + } + }, + { + "name": "Create File", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{bearer_token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"path\": \"example.yaml\",\n \"content\": \"your file content\"\n}" + }, + "url": { + "raw": "{{base_url}}/files", + "host": ["{{base_url}}"], + "path": ["files"] + } + } + }, + { + "name": "Update File", + "request": { + "method": "PUT", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{bearer_token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"content\": \"updated content\"\n}" + }, + "url": { + "raw": "{{base_url}}/files?path=example.yaml", + "host": ["{{base_url}}"], + "path": ["files"], + "query": [ + { + "key": "path", + "value": "example.yaml" + } + ] + } + } + }, + { + "name": "Delete File", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{bearer_token}}" + } + ], + "url": { + "raw": "{{base_url}}/files?path=example.yaml", + "host": ["{{base_url}}"], + "path": ["files"], + "query": [ + { + "key": "path", + "value": "example.yaml" + } + ] + } + } + }, + { + "name": "Bulk Create Files", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{bearer_token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"files\": [\n {\n \"path\": \"example1.yaml\",\n \"content\": \"content 1\"\n },\n {\n \"path\": \"example2.yaml\",\n \"content\": \"content 2\"\n }\n ]\n}" + }, + "url": { + "raw": "{{base_url}}/files/bulk", + "host": ["{{base_url}}"], + "path": ["files", "bulk"] + } + } + }, + { + "name": "Bulk Update Files", + "request": { + "method": "PUT", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{bearer_token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"files\": [\n {\n \"path\": \"example1.yaml\",\n \"content\": \"updated content 1\"\n },\n {\n \"path\": \"example2.yaml\",\n \"content\": \"updated content 2\"\n }\n ]\n}" + }, + "url": { + "raw": "{{base_url}}/files/bulk", + "host": ["{{base_url}}"], + "path": ["files", "bulk"] + } + } + }, + { + "name": "Bulk Delete Files", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{bearer_token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"files\": [\"example1.yaml\", \"example2.yaml\"]\n}" + }, + "url": { + "raw": "{{base_url}}/files/bulk", + "host": ["{{base_url}}"], + "path": ["files", "bulk"] + } + } + } + ] + }, + { + "name": "Backup Operations", + "item": [ + { + "name": "Create Backup", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{bearer_token}}" + } + ], + "url": { + "raw": "{{base_url}}/files/backup", + "host": ["{{base_url}}"], + "path": ["files", "backup"] + } + } + }, + { + "name": "Restore Backup", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{bearer_token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"backup\": \"backup-20240321-100000.zip\"\n}" + }, + "url": { + "raw": "{{base_url}}/files/restore", + "host": ["{{base_url}}"], + "path": ["files", "restore"] + } + } + } + ] + } + ] +} diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..f4146e5 --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,527 @@ +openapi: 3.0.3 +info: + title: Comanda API + description: Collection for testing Comanda server API endpoints + version: 1.0.0 + +servers: + - url: http://localhost:8080 + description: Local development server + +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + + schemas: + Success: + type: object + properties: + success: + type: boolean + enum: [true] + message: + type: string + required: + - success + + Error: + type: object + properties: + success: + type: boolean + enum: [false] + error: + type: string + required: + - success + - error + + Provider: + type: object + properties: + name: + type: string + description: Provider name (e.g., openai) + apiKey: + type: string + description: Provider API key + models: + type: array + items: + type: string + description: List of enabled models + enabled: + type: boolean + description: Whether the provider is enabled + required: + - name + - apiKey + - models + - enabled + + ProviderList: + type: object + properties: + success: + type: boolean + enum: [true] + providers: + type: array + items: + type: object + properties: + name: + type: string + models: + type: array + items: + type: string + enabled: + type: boolean + required: + - success + - providers + + EncryptionRequest: + type: object + properties: + password: + type: string + description: Password for encryption/decryption + required: + - password + + FileOperation: + type: object + properties: + path: + type: string + description: File path + content: + type: string + description: File content + required: + - path + - content + + FileMetadata: + type: object + properties: + name: + type: string + path: + type: string + size: + type: integer + isDir: + type: boolean + createdAt: + type: string + format: date-time + modifiedAt: + type: string + format: date-time + methods: + type: string + description: Supported HTTP methods (GET/POST for YAML files) + required: + - name + - path + - size + - isDir + - createdAt + - modifiedAt + + FileList: + type: object + properties: + success: + type: boolean + enum: [true] + files: + type: array + items: + $ref: '#/components/schemas/FileMetadata' + required: + - success + - files + + BulkFileOperation: + type: object + properties: + files: + type: array + items: + $ref: '#/components/schemas/FileOperation' + required: + - files + + BulkFileDelete: + type: object + properties: + files: + type: array + items: + type: string + description: List of file paths to delete + required: + - files + + RestoreBackup: + type: object + properties: + backup: + type: string + description: Backup file name (e.g., backup-20240321-100000.zip) + required: + - backup + +security: + - BearerAuth: [] + +paths: + /providers: + get: + summary: List Providers + responses: + '200': + description: List of providers retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ProviderList' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + put: + summary: Update Provider + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Provider' + example: + name: openai + apiKey: your-api-key + models: [gpt-4, gpt-3.5-turbo] + enabled: true + responses: + '200': + description: Provider updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Success' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /providers/{provider_name}: + delete: + summary: Delete Provider + parameters: + - name: provider_name + in: path + required: true + schema: + type: string + example: openai + responses: + '200': + description: Provider deleted successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Success' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /env/encrypt: + post: + summary: Encrypt Environment + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EncryptionRequest' + example: + password: your-password + responses: + '200': + description: Environment encrypted successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Success' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /env/decrypt: + post: + summary: Decrypt Environment + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EncryptionRequest' + example: + password: your-password + responses: + '200': + description: Environment decrypted successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Success' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /list: + get: + summary: List Files + responses: + '200': + description: List of files retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/FileList' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /files: + post: + summary: Create File + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FileOperation' + example: + path: example.yaml + content: your file content + responses: + '200': + description: File created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Success' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + put: + summary: Update File + parameters: + - name: path + in: query + required: true + schema: + type: string + example: example.yaml + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + content: + type: string + description: Updated file content + required: + - content + example: + content: updated content + responses: + '200': + description: File updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Success' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + delete: + summary: Delete File + parameters: + - name: path + in: query + required: true + schema: + type: string + example: example.yaml + responses: + '200': + description: File deleted successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Success' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /files/bulk: + post: + summary: Bulk Create Files + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BulkFileOperation' + example: + files: + - path: example1.yaml + content: content 1 + - path: example2.yaml + content: content 2 + responses: + '200': + description: Files created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Success' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + put: + summary: Bulk Update Files + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BulkFileOperation' + example: + files: + - path: example1.yaml + content: updated content 1 + - path: example2.yaml + content: updated content 2 + responses: + '200': + description: Files updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Success' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + delete: + summary: Bulk Delete Files + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BulkFileDelete' + example: + files: [example1.yaml, example2.yaml] + responses: + '200': + description: Files deleted successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Success' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /files/backup: + post: + summary: Create Backup + responses: + '200': + description: Backup created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Success' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /files/restore: + post: + summary: Restore Backup + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RestoreBackup' + example: + backup: backup-20240321-100000.zip + responses: + '200': + description: Backup restored successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Success' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' diff --git a/docs/server-api.md b/docs/server-api.md new file mode 100644 index 0000000..42fe474 --- /dev/null +++ b/docs/server-api.md @@ -0,0 +1,433 @@ +# Comanda Server API Documentation + +## Overview + +The Comanda server provides a RESTful API interface for managing providers, environment configuration, and file operations. The goal is to have CLI and server functionality at parity so that you can build your own UI for managing Comanda agentic workflows. + +## Authentication + +When authentication is enabled, all endpoints require a Bearer token in the Authorization header: +```http +Authorization: Bearer your-token +``` + +## API Endpoints + +### Provider Management + +The provider management API allows you to configure and manage different AI model providers (OpenAI, Anthropic, Google, etc.). + +#### List Providers +```http +GET /providers +Authorization: Bearer +``` + +Lists all configured providers and their available models. + +Response: +```json +{ + "success": true, + "providers": [ + { + "name": "openai", + "models": ["gpt-4", "gpt-3.5-turbo"], + "enabled": true + }, + { + "name": "anthropic", + "models": ["claude-2", "claude-instant"], + "enabled": true + } + ] +} +``` + +#### Update Provider +```http +PUT /providers +Authorization: Bearer +Content-Type: application/json + +{ + "name": "openai", + "apiKey": "your-api-key", + "models": ["gpt-4", "gpt-3.5-turbo"], + "enabled": true +} +``` + +Updates or adds a provider configuration. If the provider doesn't exist, it will be created. + +Response: +```json +{ + "success": true, + "message": "Provider openai updated successfully" +} +``` + +#### Delete Provider +```http +DELETE /providers/{provider_name} +Authorization: Bearer +``` + +Removes a provider configuration. + +Response: +```json +{ + "success": true, + "message": "Provider openai removed successfully" +} +``` + +### Environment Security + +The environment security API provides endpoints for encrypting and decrypting the environment configuration file. + +#### Encrypt Environment +```http +POST /env/encrypt +Authorization: Bearer +Content-Type: application/json + +{ + "password": "your-password" +} +``` + +Encrypts the environment file with the provided password. The original file will be replaced with an encrypted version. + +Response: +```json +{ + "success": true, + "message": "Environment file encrypted successfully" +} +``` + +#### Decrypt Environment +```http +POST /env/decrypt +Authorization: Bearer +Content-Type: application/json + +{ + "password": "your-password" +} +``` + +Decrypts the environment file using the provided password. The encrypted file will be replaced with the decrypted version. + +Response: +```json +{ + "success": true, + "message": "Environment file decrypted successfully" +} +``` + +### File Operations + +The file operations API provides endpoints for managing files with enhanced metadata. + +#### List Files +```http +GET /list +Authorization: Bearer +``` + +Returns a list of files with detailed metadata including creation date, modification date, and supported methods. + +Response: +```json +{ + "success": true, + "files": [ + { + "name": "example.yaml", + "path": "example.yaml", + "size": 1234, + "isDir": false, + "createdAt": "2024-03-21T10:00:00Z", + "modifiedAt": "2024-03-21T10:00:00Z", + "methods": "GET" + } + ] +} +``` + +The `methods` field indicates whether a YAML file accepts input: +- `GET`: File can be processed without input +- `POST`: File requires input for processing + +#### Create File +```http +POST /files +Authorization: Bearer +Content-Type: application/json + +{ + "path": "example.yaml", + "content": "your file content" +} +``` + +Creates a new file with the specified content. The path must be relative to the data directory. + +Response: +```json +{ + "success": true, + "message": "File created successfully", + "file": { + "name": "example.yaml", + "path": "example.yaml", + "size": 1234, + "isDir": false, + "createdAt": "2024-03-21T10:00:00Z", + "modifiedAt": "2024-03-21T10:00:00Z" + } +} +``` + +#### Update File +```http +PUT /files?path=example.yaml +Authorization: Bearer +Content-Type: application/json + +{ + "content": "updated content" +} +``` + +Updates an existing file with new content. + +Response: +```json +{ + "success": true, + "message": "File updated successfully", + "file": { + "name": "example.yaml", + "path": "example.yaml", + "size": 1234, + "isDir": false, + "createdAt": "2024-03-21T10:00:00Z", + "modifiedAt": "2024-03-21T10:00:00Z" + } +} +``` + +#### Delete File +```http +DELETE /files?path=example.yaml +Authorization: Bearer +``` + +Deletes the specified file. + +Response: +```json +{ + "success": true, + "message": "File deleted successfully" +} +``` + +## Security Features + +### Authentication +- Bearer token authentication when enabled +- Token must be provided in the Authorization header +- All endpoints check authentication if enabled + +### File Security +- Path traversal prevention +- Files are restricted to the data directory +- Proper permission checks on file operations + +### Environment Security +- Password-based encryption using AES-256-GCM +- Secure key derivation using SHA-256 +- Base64 encoding for encrypted data + +## Error Handling + +All endpoints return appropriate HTTP status codes and JSON responses with error messages: + +```json +{ + "success": false, + "error": "Detailed error message" +} +``` + +Common status codes: +- 200: Success +- 400: Bad Request (invalid input) +- 401: Unauthorized (missing or invalid token) +- 403: Forbidden (path traversal attempt) +- 404: Not Found (file or resource not found) +- 409: Conflict (file already exists) +- 500: Internal Server Error + +## Example Usage + +### Using curl + +1. List Providers: +```bash +curl -H "Authorization: Bearer your-token" \ + http://localhost:8080/providers +``` + +2. Update Provider: +```bash +curl -X PUT \ + -H "Authorization: Bearer your-token" \ + -H "Content-Type: application/json" \ + -d '{"name":"openai","apiKey":"your-api-key","models":["gpt-4"]}' \ + http://localhost:8080/providers +``` + +3. Encrypt Environment: +```bash +curl -X POST \ + -H "Authorization: Bearer your-token" \ + -H "Content-Type: application/json" \ + -d '{"password":"your-password"}' \ + http://localhost:8080/env/encrypt +``` + +4. Create File: +```bash +curl -X POST \ + -H "Authorization: Bearer your-token" \ + -H "Content-Type: application/json" \ + -d '{"path":"example.yaml","content":"your content"}' \ + http://localhost:8080/files +``` + +### Using JavaScript + +```javascript +// Base configuration +const API_URL = 'http://localhost:8080'; +const TOKEN = 'your-token'; + +const headers = { + 'Authorization': `Bearer ${TOKEN}`, + 'Content-Type': 'application/json' +}; + +// List providers +async function listProviders() { + const response = await fetch(`${API_URL}/providers`, { headers }); + return await response.json(); +} + +// Update provider +async function updateProvider(provider) { + const response = await fetch(`${API_URL}/providers`, { + method: 'PUT', + headers, + body: JSON.stringify(provider) + }); + return await response.json(); +} + +// Encrypt environment +async function encryptEnvironment(password) { + const response = await fetch(`${API_URL}/env/encrypt`, { + method: 'POST', + headers, + body: JSON.stringify({ password }) + }); + return await response.json(); +} + +// Create file +async function createFile(path, content) { + const response = await fetch(`${API_URL}/files`, { + method: 'POST', + headers, + body: JSON.stringify({ path, content }) + }); + return await response.json(); +} + +// Update file +async function updateFile(path, content) { + const response = await fetch(`${API_URL}/files?path=${encodeURIComponent(path)}`, { + method: 'PUT', + headers, + body: JSON.stringify({ content }) + }); + return await response.json(); +} + +// Delete file +async function deleteFile(path) { + const response = await fetch(`${API_URL}/files?path=${encodeURIComponent(path)}`, { + method: 'DELETE', + headers + }); + return await response.json(); +} + +// Example usage +async function example() { + try { + // List providers + const providers = await listProviders(); + console.log('Providers:', providers); + + // Update OpenAI provider + const updateResult = await updateProvider({ + name: 'openai', + apiKey: 'your-api-key', + models: ['gpt-4', 'gpt-3.5-turbo'], + enabled: true + }); + console.log('Update result:', updateResult); + + // Create a file + const createResult = await createFile('example.yaml', 'file content'); + console.log('Create result:', createResult); + } catch (error) { + console.error('Error:', error); + } +} +``` + +## Best Practices + +1. Always handle errors appropriately: + - Check response status codes + - Parse error messages from responses + - Implement proper error handling in your code + +2. Secure your bearer token: + - Never expose it in client-side code + - Use environment variables or secure configuration + - Rotate tokens periodically + +3. File operations: + - Always use relative paths + - Validate file content before sending + - Handle large files appropriately + +4. Environment security: + - Use strong passwords for encryption + - Store passwords securely + - Keep backup of environment file before encryption + +5. Provider management: + - Validate API keys before saving + - Keep track of enabled/disabled providers + - Monitor model availability diff --git a/utils/server/bulk_operations.go b/utils/server/bulk_operations.go new file mode 100644 index 0000000..6a8263e --- /dev/null +++ b/utils/server/bulk_operations.go @@ -0,0 +1,237 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + + "github.com/kris-hansen/comanda/utils/config" +) + +// handleBulkFileOperation handles bulk file operations +func (s *Server) handleBulkFileOperation(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if !checkAuth(s.config, w, r) { + return + } + + switch r.Method { + case http.MethodPost: + s.handleBulkCreate(w, r) + case http.MethodPut: + s.handleBulkUpdate(w, r) + case http.MethodDelete: + s.handleBulkDelete(w, r) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Method not allowed", + }) + } +} + +// handleBulkCreate handles bulk file creation +func (s *Server) handleBulkCreate(w http.ResponseWriter, r *http.Request) { + var req BulkFileRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + config.VerboseLog("Error decoding request: %v", err) + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(BulkFileResponse{ + Success: false, + Error: "Invalid request format", + }) + return + } + + results := make([]FileResult, 0, len(req.Files)) + success := true + + for _, file := range req.Files { + result := FileResult{ + Path: file.Path, + Success: true, + } + + // Validate path + fullPath, err := s.validatePath(file.Path) + if err != nil { + result.Success = false + result.Error = "Invalid file path: access denied" + success = false + results = append(results, result) + continue + } + + // Create directories if they don't exist + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + result.Success = false + result.Error = fmt.Sprintf("Error creating directories: %v", err) + success = false + results = append(results, result) + continue + } + + // Check if file already exists + if _, err := os.Stat(fullPath); err == nil { + result.Success = false + result.Error = "File already exists" + success = false + results = append(results, result) + continue + } + + // Write the file + if err := os.WriteFile(fullPath, []byte(file.Content), 0644); err != nil { + result.Success = false + result.Error = fmt.Sprintf("Error writing file: %v", err) + success = false + } + + results = append(results, result) + } + + response := BulkFileResponse{ + Success: success, + Results: results, + } + if !success { + response.Message = "Some files failed to be created" + } else { + response.Message = "All files created successfully" + } + + json.NewEncoder(w).Encode(response) +} + +// handleBulkUpdate handles bulk file updates +func (s *Server) handleBulkUpdate(w http.ResponseWriter, r *http.Request) { + var req BulkFileRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + config.VerboseLog("Error decoding request: %v", err) + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(BulkFileResponse{ + Success: false, + Error: "Invalid request format", + }) + return + } + + results := make([]FileResult, 0, len(req.Files)) + success := true + + for _, file := range req.Files { + result := FileResult{ + Path: file.Path, + Success: true, + } + + // Validate path + fullPath, err := s.validatePath(file.Path) + if err != nil { + result.Success = false + result.Error = "Invalid file path: access denied" + success = false + results = append(results, result) + continue + } + + // Check if file exists + if _, err := os.Stat(fullPath); os.IsNotExist(err) { + result.Success = false + result.Error = "File not found" + success = false + results = append(results, result) + continue + } + + // Write the file + if err := os.WriteFile(fullPath, []byte(file.Content), 0644); err != nil { + result.Success = false + result.Error = fmt.Sprintf("Error writing file: %v", err) + success = false + } + + results = append(results, result) + } + + response := BulkFileResponse{ + Success: success, + Results: results, + } + if !success { + response.Message = "Some files failed to be updated" + } else { + response.Message = "All files updated successfully" + } + + json.NewEncoder(w).Encode(response) +} + +// handleBulkDelete handles bulk file deletion +func (s *Server) handleBulkDelete(w http.ResponseWriter, r *http.Request) { + var req struct { + Files []string `json:"files"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + config.VerboseLog("Error decoding request: %v", err) + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(BulkFileResponse{ + Success: false, + Error: "Invalid request format", + }) + return + } + + results := make([]FileResult, 0, len(req.Files)) + success := true + + for _, path := range req.Files { + result := FileResult{ + Path: path, + Success: true, + } + + // Validate path + fullPath, err := s.validatePath(path) + if err != nil { + result.Success = false + result.Error = "Invalid file path: access denied" + success = false + results = append(results, result) + continue + } + + // Check if file exists + if _, err := os.Stat(fullPath); os.IsNotExist(err) { + result.Success = false + result.Error = "File not found" + success = false + results = append(results, result) + continue + } + + // Delete file + if err := os.Remove(fullPath); err != nil { + result.Success = false + result.Error = fmt.Sprintf("Error deleting file: %v", err) + success = false + } + + results = append(results, result) + } + + response := BulkFileResponse{ + Success: success, + Results: results, + } + if !success { + response.Message = "Some files failed to be deleted" + } else { + response.Message = "All files deleted successfully" + } + + json.NewEncoder(w).Encode(response) +} diff --git a/utils/server/bulk_operations_test.go b/utils/server/bulk_operations_test.go new file mode 100644 index 0000000..f59f5d7 --- /dev/null +++ b/utils/server/bulk_operations_test.go @@ -0,0 +1,284 @@ +package server + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/kris-hansen/comanda/utils/config" +) + +func TestHandleBulkFileOperations(t *testing.T) { + // Create a temporary directory for testing + tempDir, err := os.MkdirTemp("", "comanda-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create server instance + server := &Server{ + config: &ServerConfig{ + DataDir: tempDir, + BearerToken: "test-token", + Enabled: true, + }, + envConfig: &config.EnvConfig{}, + } + + // Test bulk create + t.Run("Bulk Create", func(t *testing.T) { + req := BulkFileRequest{ + Files: []FileRequest{ + {Path: "test1.yaml", Content: "content1"}, + {Path: "test2.yaml", Content: "content2"}, + {Path: "subdir/test3.yaml", Content: "content3"}, + {Path: "../outside.yaml", Content: "bad content"}, // Path traversal attempt + {Path: "/etc/test.yaml", Content: "bad content"}, // Absolute path attempt + {Path: "subdir/../test4.yaml", Content: "content4"}, // Path traversal attempt + {Path: "./test5.yaml", Content: "content5"}, // Current directory + {Path: "deep/nested/test6.yaml", Content: "content6"}, // Deep nesting + }, + } + + body, _ := json.Marshal(req) + httpReq := httptest.NewRequest("POST", "/files/bulk", bytes.NewBuffer(body)) + httpReq.Header.Set("Authorization", "Bearer test-token") + httpReq.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + server.handleBulkFileOperation(w, httpReq) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + } + + var response BulkFileResponse + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatal(err) + } + + if response.Success { + t.Error("Expected success to be false due to invalid paths") + } + + // Verify results + expectedResults := map[string]struct { + success bool + content string + error string + }{ + "test1.yaml": {true, "content1", ""}, + "test2.yaml": {true, "content2", ""}, + "subdir/test3.yaml": {true, "content3", ""}, + "../outside.yaml": {false, "", "Invalid file path: access denied"}, + "/etc/test.yaml": {false, "", "Invalid file path: access denied"}, + "subdir/../test4.yaml": {false, "", "Invalid file path: access denied"}, + "./test5.yaml": {true, "content5", ""}, + "deep/nested/test6.yaml": {true, "content6", ""}, + } + + // Verify each result + for _, result := range response.Results { + expected := expectedResults[result.Path] + if result.Success != expected.success { + t.Errorf("Path %s: expected success=%v got %v", result.Path, expected.success, result.Success) + } + + if !result.Success { + if result.Error != expected.error { + t.Errorf("Path %s: expected error '%s' got '%s'", result.Path, expected.error, result.Error) + } + } else { + // Verify file was created with correct content + content, err := os.ReadFile(filepath.Join(tempDir, result.Path)) + if err != nil { + t.Errorf("Error reading file %s: %v", result.Path, err) + continue + } + if string(content) != expected.content { + t.Errorf("Path %s: expected content '%s' got '%s'", result.Path, expected.content, string(content)) + } + } + } + + // Verify no files were created outside data directory + outsidePath := filepath.Join(filepath.Dir(tempDir), "outside.yaml") + if _, err := os.Stat(outsidePath); !os.IsNotExist(err) { + t.Error("File was created outside data directory") + os.Remove(outsidePath) + } + }) + + // Test bulk update + t.Run("Bulk Update", func(t *testing.T) { + // Create some files for updating + filesToCreate := map[string]string{ + "test1.yaml": "original1", + "test2.yaml": "original2", + "subdir/test3.yaml": "original3", + } + for path, content := range filesToCreate { + fullPath := filepath.Join(tempDir, path) + os.MkdirAll(filepath.Dir(fullPath), 0755) + os.WriteFile(fullPath, []byte(content), 0644) + } + + req := BulkFileRequest{ + Files: []FileRequest{ + {Path: "test1.yaml", Content: "updated1"}, + {Path: "test2.yaml", Content: "updated2"}, + {Path: "subdir/test3.yaml", Content: "updated3"}, + {Path: "nonexistent.yaml", Content: "new content"}, + {Path: "../test.yaml", Content: "bad content"}, + {Path: "/etc/test.yaml", Content: "bad content"}, + }, + } + + body, _ := json.Marshal(req) + httpReq := httptest.NewRequest("PUT", "/files/bulk", bytes.NewBuffer(body)) + httpReq.Header.Set("Authorization", "Bearer test-token") + httpReq.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + server.handleBulkFileOperation(w, httpReq) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + } + + var response BulkFileResponse + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatal(err) + } + + if response.Success { + t.Error("Expected success to be false due to invalid paths and nonexistent files") + } + + // Verify results + expectedResults := map[string]struct { + success bool + content string + error string + }{ + "test1.yaml": {true, "updated1", ""}, + "test2.yaml": {true, "updated2", ""}, + "subdir/test3.yaml": {true, "updated3", ""}, + "nonexistent.yaml": {false, "", "File not found"}, + "../test.yaml": {false, "", "Invalid file path: access denied"}, + "/etc/test.yaml": {false, "", "Invalid file path: access denied"}, + } + + // Verify each result + for _, result := range response.Results { + expected := expectedResults[result.Path] + if result.Success != expected.success { + t.Errorf("Path %s: expected success=%v got %v", result.Path, expected.success, result.Success) + } + + if !result.Success { + if result.Error != expected.error { + t.Errorf("Path %s: expected error '%s' got '%s'", result.Path, expected.error, result.Error) + } + } else { + // Verify file was updated with correct content + content, err := os.ReadFile(filepath.Join(tempDir, result.Path)) + if err != nil { + t.Errorf("Error reading file %s: %v", result.Path, err) + continue + } + if string(content) != expected.content { + t.Errorf("Path %s: expected content '%s' got '%s'", result.Path, expected.content, string(content)) + } + } + } + }) + + // Test bulk delete + t.Run("Bulk Delete", func(t *testing.T) { + // Create some files for deletion + filesToCreate := map[string]string{ + "test1.yaml": "content1", + "test2.yaml": "content2", + "subdir/test3.yaml": "content3", + } + for path, content := range filesToCreate { + fullPath := filepath.Join(tempDir, path) + os.MkdirAll(filepath.Dir(fullPath), 0755) + os.WriteFile(fullPath, []byte(content), 0644) + } + + req := struct { + Files []string `json:"files"` + }{ + Files: []string{ + "test1.yaml", + "test2.yaml", + "subdir/test3.yaml", + "nonexistent.yaml", + "../test.yaml", + "/etc/test.yaml", + "subdir/../test2.yaml", // Path traversal attempt + }, + } + + body, _ := json.Marshal(req) + httpReq := httptest.NewRequest("DELETE", "/files/bulk", bytes.NewBuffer(body)) + httpReq.Header.Set("Authorization", "Bearer test-token") + httpReq.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + server.handleBulkFileOperation(w, httpReq) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + } + + var response BulkFileResponse + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatal(err) + } + + if response.Success { + t.Error("Expected success to be false due to invalid paths and nonexistent files") + } + + // Verify results + expectedResults := map[string]struct { + success bool + error string + }{ + "test1.yaml": {true, ""}, + "test2.yaml": {true, ""}, + "subdir/test3.yaml": {true, ""}, + "nonexistent.yaml": {false, "File not found"}, + "../test.yaml": {false, "Invalid file path: access denied"}, + "/etc/test.yaml": {false, "Invalid file path: access denied"}, + "subdir/../test2.yaml": {false, "Invalid file path: access denied"}, + } + + // Verify each result + for _, result := range response.Results { + expected := expectedResults[result.Path] + if result.Success != expected.success { + t.Errorf("Path %s: expected success=%v got %v", result.Path, expected.success, result.Success) + } + + if !result.Success && result.Error != expected.error { + t.Errorf("Path %s: expected error '%s' got '%s'", result.Path, expected.error, result.Error) + } + + if result.Success { + // Verify file was deleted + if _, err := os.Stat(filepath.Join(tempDir, result.Path)); !os.IsNotExist(err) { + t.Errorf("File %s was not deleted", result.Path) + } + } + } + }) +} diff --git a/utils/server/env_handlers.go b/utils/server/env_handlers.go new file mode 100644 index 0000000..b55dc21 --- /dev/null +++ b/utils/server/env_handlers.go @@ -0,0 +1,148 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + + "github.com/kris-hansen/comanda/utils/config" +) + +// handleEncryptEnv handles environment file encryption +func (s *Server) handleEncryptEnv(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if !checkAuth(s.config, w, r) { + return + } + + var req struct { + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(EnvironmentResponse{ + Success: false, + Error: "Invalid request format", + }) + return + } + + if req.Password == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(EnvironmentResponse{ + Success: false, + Error: "Password is required", + }) + return + } + + // Get environment file path + envPath := config.GetEnvPath() + + // Create directory if it doesn't exist + if err := os.MkdirAll(s.config.DataDir, 0755); err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(EnvironmentResponse{ + Success: false, + Error: fmt.Sprintf("Error creating directory: %v", err), + }) + return + } + + // Encrypt the file + if err := config.EncryptConfig(envPath, req.Password); err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(EnvironmentResponse{ + Success: false, + Error: fmt.Sprintf("Error encrypting file: %v", err), + }) + return + } + + json.NewEncoder(w).Encode(EnvironmentResponse{ + Success: true, + Message: "Environment file encrypted successfully", + }) +} + +// handleDecryptEnv handles environment file decryption +func (s *Server) handleDecryptEnv(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if !checkAuth(s.config, w, r) { + return + } + + var req struct { + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(EnvironmentResponse{ + Success: false, + Error: "Invalid request format", + }) + return + } + + if req.Password == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(EnvironmentResponse{ + Success: false, + Error: "Password is required", + }) + return + } + + // Get environment file path + envPath := config.GetEnvPath() + + // Read encrypted file + data, err := os.ReadFile(envPath) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(EnvironmentResponse{ + Success: false, + Error: fmt.Sprintf("Error reading file: %v", err), + }) + return + } + + // Verify file is encrypted + if !config.IsEncrypted(data) { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(EnvironmentResponse{ + Success: false, + Error: "File is not encrypted", + }) + return + } + + // Decrypt the data + decrypted, err := config.DecryptConfig(data, req.Password) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(EnvironmentResponse{ + Success: false, + Error: fmt.Sprintf("Error decrypting file: %v", err), + }) + return + } + + // Write decrypted data back to file + if err := os.WriteFile(envPath, decrypted, 0644); err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(EnvironmentResponse{ + Success: false, + Error: fmt.Sprintf("Error writing file: %v", err), + }) + return + } + + json.NewEncoder(w).Encode(EnvironmentResponse{ + Success: true, + Message: "Environment file decrypted successfully", + }) +} diff --git a/utils/server/env_handlers_test.go b/utils/server/env_handlers_test.go new file mode 100644 index 0000000..51ed7f4 --- /dev/null +++ b/utils/server/env_handlers_test.go @@ -0,0 +1,230 @@ +package server + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/kris-hansen/comanda/utils/config" + "gopkg.in/yaml.v3" +) + +func TestHandleEncryptEnv(t *testing.T) { + // Create a temporary directory for testing + tempDir, err := os.MkdirTemp("", "comanda-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create a test environment file + envPath := filepath.Join(tempDir, ".env") + testConfig := &config.EnvConfig{ + Providers: map[string]*config.Provider{ + "test": { + APIKey: "test-key", + }, + }, + } + if err := config.SaveEnvConfig(envPath, testConfig); err != nil { + t.Fatal(err) + } + + // Set environment variable for test + os.Setenv("COMANDA_ENV", envPath) + defer os.Unsetenv("COMANDA_ENV") + + // Create server instance + server := &Server{ + config: &ServerConfig{ + DataDir: tempDir, + BearerToken: "test-token", + Enabled: true, + }, + envConfig: testConfig, + } + + // Create test request body + body := EnvironmentRequest{ + Password: "test-password", + } + bodyBytes, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + + // Create test request + req := httptest.NewRequest("POST", "/env/encrypt", bytes.NewBuffer(bodyBytes)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + // Call handler + server.handleEncryptEnv(w, req) + + // Check response + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + } + + var response EnvironmentResponse + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatal(err) + } + + if !response.Success { + t.Error("Expected success to be true") + } + + // Verify file was encrypted + content, err := os.ReadFile(envPath) + if err != nil { + t.Fatal(err) + } + if !config.IsEncrypted(content) { + t.Error("Expected file to be encrypted") + } +} + +func TestHandleDecryptEnv(t *testing.T) { + // Create a temporary directory for testing + tempDir, err := os.MkdirTemp("", "comanda-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create a test environment file + envPath := filepath.Join(tempDir, ".env") + testConfig := &config.EnvConfig{ + Providers: map[string]*config.Provider{ + "test": { + APIKey: "test-key", + }, + }, + } + if err := config.SaveEnvConfig(envPath, testConfig); err != nil { + t.Fatal(err) + } + + // Encrypt the file first + password := "test-password" + if err := config.EncryptConfig(envPath, password); err != nil { + t.Fatal(err) + } + + // Set environment variable for test + os.Setenv("COMANDA_ENV", envPath) + defer os.Unsetenv("COMANDA_ENV") + + // Create server instance + server := &Server{ + config: &ServerConfig{ + DataDir: tempDir, + BearerToken: "test-token", + Enabled: true, + }, + envConfig: testConfig, + } + + // Create test request body + body := EnvironmentRequest{ + Password: password, + } + bodyBytes, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + + // Create test request + req := httptest.NewRequest("POST", "/env/decrypt", bytes.NewBuffer(bodyBytes)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + // Call handler + server.handleDecryptEnv(w, req) + + // Check response + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + } + + var response EnvironmentResponse + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatal(err) + } + + if !response.Success { + t.Error("Expected success to be true") + } + + // Verify file was decrypted + content, err := os.ReadFile(envPath) + if err != nil { + t.Fatal(err) + } + if config.IsEncrypted(content) { + t.Error("Expected file to be decrypted") + } + + // Verify decrypted content matches original using YAML unmarshaling + var decryptedConfig config.EnvConfig + if err := yaml.Unmarshal(content, &decryptedConfig); err != nil { + t.Fatal(err) + } + + if decryptedConfig.Providers["test"].APIKey != testConfig.Providers["test"].APIKey { + t.Error("Decrypted content does not match original") + } +} + +func TestHandleEncryptEnvWithoutPassword(t *testing.T) { + // Create server instance + server := &Server{ + config: &ServerConfig{ + BearerToken: "test-token", + Enabled: true, + }, + } + + // Create test request with empty password + body := EnvironmentRequest{ + Password: "", + } + bodyBytes, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + + // Create test request + req := httptest.NewRequest("POST", "/env/encrypt", bytes.NewBuffer(bodyBytes)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + // Call handler + server.handleEncryptEnv(w, req) + + // Check response + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status code %d, got %d", http.StatusBadRequest, w.Code) + } + + var response EnvironmentResponse + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatal(err) + } + + if response.Success { + t.Error("Expected success to be false") + } + + if response.Error != "Password is required" { + t.Errorf("Expected error message 'Password is required', got '%s'", response.Error) + } +} diff --git a/utils/server/file_backup.go b/utils/server/file_backup.go new file mode 100644 index 0000000..94a5407 --- /dev/null +++ b/utils/server/file_backup.go @@ -0,0 +1,274 @@ +package server + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/kris-hansen/comanda/utils/config" +) + +// handleFileBackup handles creating a backup of files +func (s *Server) handleFileBackup(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if !checkAuth(s.config, w, r) { + return + } + + // Create backup directory if it doesn't exist + backupDir := filepath.Join(s.config.DataDir, "backups") + if err := os.MkdirAll(backupDir, 0755); err != nil { + config.VerboseLog("Error creating backup directory: %v", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(BackupResponse{ + Success: false, + Error: fmt.Sprintf("Error creating backup directory: %v", err), + }) + return + } + + // Create backup file + timestamp := time.Now().Format("20060102-150405") + backupName := fmt.Sprintf("backup-%s.zip", timestamp) + backupPath := filepath.Join(backupDir, backupName) + + zipFile, err := os.Create(backupPath) + if err != nil { + config.VerboseLog("Error creating backup file: %v", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(BackupResponse{ + Success: false, + Error: fmt.Sprintf("Error creating backup file: %v", err), + }) + return + } + defer zipFile.Close() + + // Create zip writer + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + // Walk through data directory + err = filepath.Walk(s.config.DataDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip backup directory + if path == backupDir || strings.HasPrefix(path, backupDir) { + return nil + } + + // Get relative path + relPath, err := filepath.Rel(s.config.DataDir, path) + if err != nil { + return err + } + + // Skip if this is the root directory + if relPath == "." { + return nil + } + + // Create zip entry + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + + header.Name = relPath + header.Method = zip.Deflate + + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return err + } + + if !info.IsDir() { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + _, err = io.Copy(writer, file) + if err != nil { + return err + } + } + + return nil + }) + + if err != nil { + config.VerboseLog("Error creating backup: %v", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(BackupResponse{ + Success: false, + Error: fmt.Sprintf("Error creating backup: %v", err), + }) + return + } + + json.NewEncoder(w).Encode(BackupResponse{ + Success: true, + Message: "Backup created successfully", + Filename: backupName, + }) +} + +// handleFileRestore handles restoring files from a backup +func (s *Server) handleFileRestore(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if !checkAuth(s.config, w, r) { + return + } + + var req RestoreRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + config.VerboseLog("Error decoding request: %v", err) + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(BackupResponse{ + Success: false, + Error: "Invalid request format", + }) + return + } + + // Validate backup name + if req.Backup == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(BackupResponse{ + Success: false, + Error: "Backup name is required", + }) + return + } + + // Check for path traversal or absolute paths in backup name + if strings.Contains(req.Backup, "..") || strings.Contains(req.Backup, "/") || strings.Contains(req.Backup, "\\") || filepath.IsAbs(req.Backup) { + config.VerboseLog("Invalid backup path: %s", req.Backup) + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(BackupResponse{ + Success: false, + Error: "Invalid backup path", + }) + return + } + + // Get full backup path + fullBackupPath := filepath.Join(s.config.DataDir, "backups", req.Backup) + + // Check if the backup exists + if _, err := os.Stat(fullBackupPath); os.IsNotExist(err) { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(BackupResponse{ + Success: false, + Error: "Backup file not found", + }) + return + } + + // Open zip file + zipReader, err := zip.OpenReader(fullBackupPath) + if err != nil { + config.VerboseLog("Error opening backup file: %v", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(BackupResponse{ + Success: false, + Error: fmt.Sprintf("Error opening backup file: %v", err), + }) + return + } + defer zipReader.Close() + + // Extract files + for _, file := range zipReader.File { + // Validate file path + fullPath, err := s.validatePath(file.Name) + if err != nil { + config.VerboseLog("Path traversal attempt: %s", file.Name) + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(BackupResponse{ + Success: false, + Error: "Invalid file path: access denied", + }) + return + } + + if file.FileInfo().IsDir() { + os.MkdirAll(fullPath, file.Mode()) + continue + } + + // Create directory for file if needed + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + config.VerboseLog("Error creating directory: %v", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(BackupResponse{ + Success: false, + Error: fmt.Sprintf("Error creating directory: %v", err), + }) + return + } + + // Create file + outFile, err := os.OpenFile(fullPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode()) + if err != nil { + config.VerboseLog("Error creating file: %v", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(BackupResponse{ + Success: false, + Error: fmt.Sprintf("Error creating file: %v", err), + }) + return + } + + // Open zip file + rc, err := file.Open() + if err != nil { + outFile.Close() + config.VerboseLog("Error opening zip file: %v", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(BackupResponse{ + Success: false, + Error: fmt.Sprintf("Error opening zip file: %v", err), + }) + return + } + + // Copy content + _, err = io.Copy(outFile, rc) + outFile.Close() + rc.Close() + + if err != nil { + config.VerboseLog("Error copying file: %v", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(BackupResponse{ + Success: false, + Error: fmt.Sprintf("Error copying file: %v", err), + }) + return + } + } + + json.NewEncoder(w).Encode(BackupResponse{ + Success: true, + Message: "Files restored successfully", + }) +} diff --git a/utils/server/file_backup_test.go b/utils/server/file_backup_test.go new file mode 100644 index 0000000..99c29f3 --- /dev/null +++ b/utils/server/file_backup_test.go @@ -0,0 +1,289 @@ +package server + +import ( + "archive/zip" + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kris-hansen/comanda/utils/config" +) + +func TestFileBackupAndRestore(t *testing.T) { + // Create a temporary directory for testing + tempDir, err := os.MkdirTemp("", "comanda-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create test files + files := map[string]string{ + "test1.yaml": "content1", + "test2.yaml": "content2", + "subdir/test3.yaml": "content3", + "deep/nested/test.yaml": "nested content", + } + + for path, content := range files { + fullPath := filepath.Join(tempDir, path) + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + + // Create server instance + server := &Server{ + config: &ServerConfig{ + DataDir: tempDir, + BearerToken: "test-token", + Enabled: true, + }, + envConfig: &config.EnvConfig{}, + } + + var backupFilename string + + // Test backup creation + t.Run("Create Backup", func(t *testing.T) { + req := httptest.NewRequest("POST", "/files/backup", nil) + req.Header.Set("Authorization", "Bearer test-token") + w := httptest.NewRecorder() + + server.handleFileBackup(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + } + + var response BackupResponse + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatal(err) + } + + if !response.Success { + t.Error("Expected success to be true") + } + + if !strings.HasPrefix(response.Filename, "backup-") || !strings.HasSuffix(response.Filename, ".zip") { + t.Errorf("Invalid backup filename format: %s", response.Filename) + } + + backupFilename = response.Filename + + // Verify backup file exists + backupPath := filepath.Join(tempDir, "backups", backupFilename) + if _, err := os.Stat(backupPath); os.IsNotExist(err) { + t.Error("Backup file was not created") + } + + // Verify backup contents + reader, err := zip.OpenReader(backupPath) + if err != nil { + t.Fatal(err) + } + defer reader.Close() + + foundFiles := make(map[string]bool) + for _, file := range reader.File { + foundFiles[file.Name] = true + + if content, ok := files[file.Name]; ok { + rc, err := file.Open() + if err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(rc) + rc.Close() + if err != nil { + t.Fatal(err) + } + if string(data) != content { + t.Errorf("File content mismatch for %s", file.Name) + } + } + } + + for path := range files { + if !foundFiles[path] { + t.Errorf("File %s not found in backup", path) + } + } + }) + + // Test restore with path traversal attempt + t.Run("Restore with Path Traversal", func(t *testing.T) { + // Create a malicious zip file + maliciousZip := filepath.Join(tempDir, "backups", "malicious.zip") + zipFile, err := os.Create(maliciousZip) + if err != nil { + t.Fatal(err) + } + + zipWriter := zip.NewWriter(zipFile) + writer, err := zipWriter.Create("../outside.txt") + if err != nil { + t.Fatal(err) + } + writer.Write([]byte("malicious content")) + zipWriter.Close() + zipFile.Close() + + // Try to restore the malicious backup + req := RestoreRequest{ + Backup: "malicious.zip", + } + body, _ := json.Marshal(req) + httpReq := httptest.NewRequest("POST", "/files/restore", bytes.NewBuffer(body)) + httpReq.Header.Set("Authorization", "Bearer test-token") + httpReq.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + server.handleFileRestore(w, httpReq) + + if w.Code != http.StatusForbidden { + t.Errorf("Expected status code %d, got %d", http.StatusForbidden, w.Code) + } + + var response BackupResponse + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatal(err) + } + + if response.Success { + t.Error("Expected restore to fail") + } + + if !strings.Contains(response.Error, "Invalid file path") { + t.Errorf("Expected error about invalid path, got: %s", response.Error) + } + + // Verify no files were created outside data directory + outsidePath := filepath.Join(filepath.Dir(tempDir), "outside.txt") + if _, err := os.Stat(outsidePath); !os.IsNotExist(err) { + t.Error("File was created outside data directory") + os.Remove(outsidePath) + } + }) + + // Test restore with invalid backup path + t.Run("Restore with Invalid Backup Path", func(t *testing.T) { + tests := []struct { + name string + backup string + expectedStatus int + expectedError string + }{ + { + name: "Path traversal in backup name", + backup: "../malicious.zip", + expectedStatus: http.StatusBadRequest, + expectedError: "Invalid backup path", + }, + { + name: "Absolute path in backup name", + backup: "/etc/malicious.zip", + expectedStatus: http.StatusBadRequest, + expectedError: "Invalid backup path", + }, + { + name: "Empty backup name", + backup: "", + expectedStatus: http.StatusBadRequest, + expectedError: "Backup name is required", + }, + { + name: "Nonexistent backup", + backup: "nonexistent.zip", + expectedStatus: http.StatusNotFound, + expectedError: "Backup file not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := RestoreRequest{ + Backup: tt.backup, + } + body, _ := json.Marshal(req) + httpReq := httptest.NewRequest("POST", "/files/restore", bytes.NewBuffer(body)) + httpReq.Header.Set("Authorization", "Bearer test-token") + httpReq.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + server.handleFileRestore(w, httpReq) + + if w.Code != tt.expectedStatus { + t.Errorf("Expected status code %d, got %d", tt.expectedStatus, w.Code) + } + + var response BackupResponse + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatal(err) + } + + if response.Success { + t.Error("Expected restore to fail") + } + + if response.Error != tt.expectedError { + t.Errorf("Expected error '%s', got '%s'", tt.expectedError, response.Error) + } + }) + } + }) + + // Test restore with valid backup + t.Run("Restore Valid Backup", func(t *testing.T) { + // Delete all test files first + for path := range files { + os.Remove(filepath.Join(tempDir, path)) + } + + // Restore from backup + req := RestoreRequest{ + Backup: backupFilename, + } + body, _ := json.Marshal(req) + httpReq := httptest.NewRequest("POST", "/files/restore", bytes.NewBuffer(body)) + httpReq.Header.Set("Authorization", "Bearer test-token") + httpReq.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + server.handleFileRestore(w, httpReq) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + } + + var response BackupResponse + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatal(err) + } + + if !response.Success { + t.Errorf("Restore failed: %s", response.Error) + } + + // Verify all files were restored with correct content + for path, expectedContent := range files { + content, err := os.ReadFile(filepath.Join(tempDir, path)) + if err != nil { + t.Errorf("Error reading restored file %s: %v", path, err) + continue + } + if string(content) != expectedContent { + t.Errorf("Restored content mismatch for %s", path) + } + } + }) +} diff --git a/utils/server/file_handlers.go b/utils/server/file_handlers.go new file mode 100644 index 0000000..8c07617 --- /dev/null +++ b/utils/server/file_handlers.go @@ -0,0 +1,353 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/kris-hansen/comanda/utils/config" +) + +// handleListFiles returns a list of files with detailed metadata +func (s *Server) handleListFiles(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if !checkAuth(s.config, w, r) { + return + } + + config.VerboseLog("Listing files in data directory") + config.DebugLog("Scanning directory: %s", s.config.DataDir) + + files, err := s.listFilesWithMetadata(s.config.DataDir) + if err != nil { + config.VerboseLog("Error listing files: %v", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(ListResponse{ + Success: false, + Error: fmt.Sprintf("Error listing files: %v", err), + }) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(ListResponse{ + Success: true, + Files: files, + }) +} + +// listFilesWithMetadata returns detailed information about files in a directory +func (s *Server) listFilesWithMetadata(dir string) ([]FileInfo, error) { + var files []FileInfo + + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip the directory itself + if path == dir { + return nil + } + + relPath, err := filepath.Rel(dir, path) + if err != nil { + return err + } + + // For YAML files, determine if they require STDIN + methods := "" + if strings.HasSuffix(info.Name(), ".yaml") { + content, err := os.ReadFile(path) + if err == nil { + if hasStdinInput(content) { + methods = "POST" + } else { + methods = "GET" + } + } + } + + files = append(files, FileInfo{ + Name: info.Name(), + Path: relPath, + Size: info.Size(), + IsDir: info.IsDir(), + CreatedAt: info.ModTime(), // Note: CreatedAt falls back to ModTime on some systems + ModifiedAt: info.ModTime(), + Methods: methods, + }) + + return nil + }) + + return files, err +} + +// handleFileOperation handles file operations (create, update, delete) +func (s *Server) handleFileOperation(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if !checkAuth(s.config, w, r) { + return + } + + var filePath string + var content string + + if r.Method == http.MethodDelete { + filePath = r.URL.Query().Get("path") + // For delete operations, empty path is not allowed + if filePath == "" { + config.VerboseLog("Empty path parameter") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(FileResponse{ + Success: false, + Error: "Path parameter is required", + }) + return + } + } else { + var req FileRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + config.VerboseLog("Error decoding request: %v", err) + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(FileResponse{ + Success: false, + Error: "Invalid request format", + }) + return + } + filePath = req.Path + content = req.Content + + // For create/update operations, empty path means create in root with default name + if filePath == "" { + filePath = "file.txt" + } + } + + // Validate path before any cleaning or manipulation + if strings.Contains(filePath, "../") || strings.Contains(filePath, "..\\") { + config.VerboseLog("Path traversal attempt: %s", filePath) + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(FileResponse{ + Success: false, + Error: "Invalid file path: access denied", + }) + return + } + + // Clean the path and check for empty result + cleanPath := filepath.Clean(filePath) + if cleanPath == "." { + cleanPath = "file.txt" + } + + // Validate cleaned path + fullPath, err := s.validatePath(cleanPath) + if err != nil { + config.VerboseLog("Invalid path: %v", err) + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(FileResponse{ + Success: false, + Error: "Invalid file path: access denied", + }) + return + } + + switch r.Method { + case http.MethodPost: + s.handleCreateFile(w, r, fullPath, content) + case http.MethodPut: + s.handleUpdateFile(w, r, fullPath, content) + case http.MethodDelete: + s.handleDeleteFile(w, r, fullPath) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + json.NewEncoder(w).Encode(FileResponse{ + Success: false, + Error: "Method not allowed", + }) + } +} + +// handleCreateFile handles file creation +func (s *Server) handleCreateFile(w http.ResponseWriter, r *http.Request, path string, content string) { + // Check if file already exists first + if _, err := os.Stat(path); err == nil { + config.VerboseLog("File already exists: %s", path) + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(FileResponse{ + Success: false, + Error: "File already exists", + }) + return + } else if !os.IsNotExist(err) { + // Handle other errors + config.VerboseLog("Error checking file: %v", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(FileResponse{ + Success: false, + Error: fmt.Sprintf("Error checking file: %v", err), + }) + return + } + + // Create directories if they don't exist + dirPath := filepath.Dir(path) + if err := os.MkdirAll(dirPath, 0755); err != nil { + config.VerboseLog("Error creating directories: %v", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(FileResponse{ + Success: false, + Error: fmt.Sprintf("Error creating directories: %v", err), + }) + return + } + + // Write the file + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + config.VerboseLog("Error writing file: %v", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(FileResponse{ + Success: false, + Error: fmt.Sprintf("Error writing file: %v", err), + }) + return + } + + // Get file info for response + fileInfo, err := s.getFileInfo(path) + if err != nil { + config.VerboseLog("Error getting file info: %v", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(FileResponse{ + Success: false, + Error: fmt.Sprintf("Error getting file info: %v", err), + }) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(FileResponse{ + Success: true, + Message: "File created successfully", + File: fileInfo, + }) +} + +// handleUpdateFile handles file updates +func (s *Server) handleUpdateFile(w http.ResponseWriter, r *http.Request, path string, content string) { + // Check if file exists + if _, err := os.Stat(path); os.IsNotExist(err) { + config.VerboseLog("File not found: %s", path) + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(FileResponse{ + Success: false, + Error: "File not found", + }) + return + } + + // Write the file + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + config.VerboseLog("Error writing file: %v", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(FileResponse{ + Success: false, + Error: fmt.Sprintf("Error writing file: %v", err), + }) + return + } + + // Get file info for response + fileInfo, err := s.getFileInfo(path) + if err != nil { + config.VerboseLog("Error getting file info: %v", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(FileResponse{ + Success: false, + Error: fmt.Sprintf("Error getting file info: %v", err), + }) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(FileResponse{ + Success: true, + Message: "File updated successfully", + File: fileInfo, + }) +} + +// handleDeleteFile handles file deletion +func (s *Server) handleDeleteFile(w http.ResponseWriter, r *http.Request, path string) { + // Check if file exists + if _, err := os.Stat(path); os.IsNotExist(err) { + config.VerboseLog("File not found: %s", path) + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(FileResponse{ + Success: false, + Error: "File not found", + }) + return + } + + // Delete the file + if err := os.Remove(path); err != nil { + config.VerboseLog("Error deleting file: %v", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(FileResponse{ + Success: false, + Error: fmt.Sprintf("Error deleting file: %v", err), + }) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(FileResponse{ + Success: true, + Message: "File deleted successfully", + }) +} + +// getFileInfo returns detailed information about a file +func (s *Server) getFileInfo(path string) (FileInfo, error) { + info, err := os.Stat(path) + if err != nil { + return FileInfo{}, err + } + + relPath, err := filepath.Rel(s.config.DataDir, path) + if err != nil { + return FileInfo{}, err + } + + // For YAML files, determine if they require STDIN + methods := "" + if strings.HasSuffix(info.Name(), ".yaml") { + content, err := os.ReadFile(path) + if err == nil { + if hasStdinInput(content) { + methods = "POST" + } else { + methods = "GET" + } + } + } + + return FileInfo{ + Name: info.Name(), + Path: relPath, + Size: info.Size(), + IsDir: info.IsDir(), + CreatedAt: info.ModTime(), // Note: CreatedAt falls back to ModTime on some systems + ModifiedAt: info.ModTime(), + Methods: methods, + }, nil +} diff --git a/utils/server/file_handlers_test.go b/utils/server/file_handlers_test.go new file mode 100644 index 0000000..e770734 --- /dev/null +++ b/utils/server/file_handlers_test.go @@ -0,0 +1,348 @@ +package server + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kris-hansen/comanda/utils/config" +) + +func TestHandleCreateFile(t *testing.T) { + // Create a temporary directory for testing + tempDir, err := os.MkdirTemp("", "comanda-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create server instance + server := &Server{ + config: &ServerConfig{ + DataDir: tempDir, + BearerToken: "test-token", + Enabled: true, + }, + envConfig: &config.EnvConfig{}, + } + + // Test cases + tests := []struct { + name string + request FileRequest + expectedStatus int + expectedError string + }{ + { + name: "Valid file creation", + request: FileRequest{ + Path: "test.yaml", + Content: "test content", + }, + expectedStatus: http.StatusOK, + }, + { + name: "Create file in subdirectory", + request: FileRequest{ + Path: "subdir/test.yaml", + Content: "test content", + }, + expectedStatus: http.StatusOK, + }, + { + name: "Path traversal attempt with ..", + request: FileRequest{ + Path: "../test.yaml", + Content: "test content", + }, + expectedStatus: http.StatusForbidden, + expectedError: "Invalid file path: access denied", + }, + { + name: "Path traversal attempt with /../", + request: FileRequest{ + Path: "subdir/../outside.yaml", + Content: "test content", + }, + expectedStatus: http.StatusForbidden, + expectedError: "Invalid file path: access denied", + }, + { + name: "Path traversal attempt with /../../", + request: FileRequest{ + Path: "subdir/../../outside.yaml", + Content: "test content", + }, + expectedStatus: http.StatusForbidden, + expectedError: "Invalid file path: access denied", + }, + { + name: "Absolute path attempt", + request: FileRequest{ + Path: "/etc/test.yaml", + Content: "test content", + }, + expectedStatus: http.StatusForbidden, + expectedError: "Invalid file path: access denied", + }, + { + name: "Empty path", + request: FileRequest{ + Path: "", + Content: "test content", + }, + expectedStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create request body + body, err := json.Marshal(tt.request) + if err != nil { + t.Fatal(err) + } + + // Create test request + req := httptest.NewRequest("POST", "/files", bytes.NewBuffer(body)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + // Call handler + server.handleFileOperation(w, req) + + // Check status code + if w.Code != tt.expectedStatus { + t.Errorf("Expected status code %d, got %d", tt.expectedStatus, w.Code) + } + + var response FileResponse + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatal(err) + } + + // Check error message if expected + if tt.expectedError != "" { + if response.Error != tt.expectedError { + t.Errorf("Expected error '%s', got '%s'", tt.expectedError, response.Error) + } + + // Verify file was not created for error cases + if _, err := os.Stat(filepath.Join(tempDir, tt.request.Path)); !os.IsNotExist(err) { + t.Error("File should not have been created") + } + + // For path traversal attempts, verify no file was created outside data directory + if strings.Contains(tt.request.Path, "..") { + parentPath := filepath.Join(filepath.Dir(tempDir), "outside.yaml") + if _, err := os.Stat(parentPath); !os.IsNotExist(err) { + t.Error("File was created outside data directory") + os.Remove(parentPath) + } + } + } else { + // For empty path, verify file was created in data directory + var filePath string + if tt.request.Path == "" { + filePath = filepath.Join(tempDir, "file.txt") + } else { + filePath = filepath.Join(tempDir, tt.request.Path) + } + + // Verify file was created with correct content + content, err := os.ReadFile(filePath) + if err != nil { + t.Fatal(err) + } + if string(content) != tt.request.Content { + t.Errorf("Expected content '%s', got '%s'", tt.request.Content, string(content)) + } + + // Verify file info in response + expectedPath := tt.request.Path + if expectedPath == "" { + expectedPath = "file.txt" + } + if response.File.Path != expectedPath { + t.Errorf("Expected path '%s', got '%s'", expectedPath, response.File.Path) + } + } + }) + } + + // Test creating an existing file + t.Run("Create existing file", func(t *testing.T) { + // Create a file first + existingPath := "existing.yaml" + existingFile := filepath.Join(tempDir, existingPath) + if err := os.WriteFile(existingFile, []byte("initial content"), 0644); err != nil { + t.Fatal(err) + } + + req := FileRequest{ + Path: existingPath, + Content: "new content", + } + body, _ := json.Marshal(req) + httpReq := httptest.NewRequest("POST", "/files", bytes.NewBuffer(body)) + httpReq.Header.Set("Authorization", "Bearer test-token") + httpReq.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + server.handleFileOperation(w, httpReq) + + if w.Code != http.StatusConflict { + t.Errorf("Expected status code %d, got %d", http.StatusConflict, w.Code) + } + + var response FileResponse + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatal(err) + } + + if response.Error != "File already exists" { + t.Errorf("Expected error 'File already exists', got '%s'", response.Error) + } + + // Verify original content wasn't changed + content, err := os.ReadFile(existingFile) + if err != nil { + t.Fatal(err) + } + if string(content) != "initial content" { + t.Error("Original file content was modified") + } + }) +} + +func TestHandleDeleteFile(t *testing.T) { + // Create a temporary directory for testing + tempDir, err := os.MkdirTemp("", "comanda-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create test files + files := map[string]string{ + "test.yaml": "test content", + "subdir/test.yaml": "test content", + } + + for path, content := range files { + fullPath := filepath.Join(tempDir, path) + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + + // Create server instance + server := &Server{ + config: &ServerConfig{ + DataDir: tempDir, + BearerToken: "test-token", + Enabled: true, + }, + envConfig: &config.EnvConfig{}, + } + + // Test cases + tests := []struct { + name string + path string + expectedStatus int + expectedError string + }{ + { + name: "Valid delete", + path: "test.yaml", + expectedStatus: http.StatusOK, + }, + { + name: "Delete file in subdirectory", + path: "subdir/test.yaml", + expectedStatus: http.StatusOK, + }, + { + name: "Delete nonexistent file", + path: "nonexistent.yaml", + expectedStatus: http.StatusNotFound, + expectedError: "File not found", + }, + { + name: "Path traversal attempt", + path: "../test.yaml", + expectedStatus: http.StatusForbidden, + expectedError: "Invalid file path: access denied", + }, + { + name: "Path traversal attempt with /../", + path: "subdir/../outside.yaml", + expectedStatus: http.StatusForbidden, + expectedError: "Invalid file path: access denied", + }, + { + name: "Absolute path attempt", + path: "/etc/test.yaml", + expectedStatus: http.StatusForbidden, + expectedError: "Invalid file path: access denied", + }, + { + name: "Empty path", + path: "", + expectedStatus: http.StatusBadRequest, + expectedError: "Path parameter is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create test request + req := httptest.NewRequest("DELETE", "/files?path="+tt.path, nil) + req.Header.Set("Authorization", "Bearer test-token") + w := httptest.NewRecorder() + + // Call handler + server.handleFileOperation(w, req) + + // Check status code + if w.Code != tt.expectedStatus { + t.Errorf("Expected status code %d, got %d", tt.expectedStatus, w.Code) + } + + var response FileResponse + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatal(err) + } + + // Check error message if expected + if tt.expectedError != "" { + if response.Error != tt.expectedError { + t.Errorf("Expected error '%s', got '%s'", tt.expectedError, response.Error) + } + + // For path traversal attempts, verify no file was deleted outside data directory + if strings.Contains(tt.path, "..") { + parentPath := filepath.Join(filepath.Dir(tempDir), "test.yaml") + if _, err := os.Stat(parentPath); err == nil { + t.Error("File outside data directory was affected") + } + } + } else { + // Verify file was deleted + if _, err := os.Stat(filepath.Join(tempDir, tt.path)); !os.IsNotExist(err) { + t.Error("Expected file to be deleted") + } + } + }) + } +} diff --git a/utils/server/handlers.go b/utils/server/handlers.go index e58e854..c1e213c 100644 --- a/utils/server/handlers.go +++ b/utils/server/handlers.go @@ -97,7 +97,7 @@ func handleProcess(w http.ResponseWriter, r *http.Request, serverConfig *ServerC return } - // Check if the YAML requires STDIN input using the existing function from auth.go + // Check if the YAML requires STDIN input requiresStdin := hasStdinInput(yamlContent) config.DebugLog("YAML STDIN requirement: %v", requiresStdin) diff --git a/utils/server/handlers_test.go b/utils/server/handlers_test.go index 68e9fc0..5aeb63b 100644 --- a/utils/server/handlers_test.go +++ b/utils/server/handlers_test.go @@ -4,8 +4,8 @@ import ( "testing" "github.com/kris-hansen/comanda/utils/processor" - "gopkg.in/yaml.v3" "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" ) func TestYAMLParsingParity(t *testing.T) { @@ -51,28 +51,36 @@ summarize: } // Verify both methods produce identical results - assert.Equal(t, len(cliConfig.Steps), len(serverConfig.Steps), + assert.Equal(t, len(cliConfig.Steps), len(serverConfig.Steps), "CLI and server should parse the same number of steps") - // Compare each step in detail - for i := 0; i < len(cliConfig.Steps); i++ { - cliStep := cliConfig.Steps[i] - serverStep := serverConfig.Steps[i] + // Create maps for easier comparison since order isn't guaranteed + cliSteps := make(map[string]processor.Step) + serverSteps := make(map[string]processor.Step) + + for _, step := range cliConfig.Steps { + cliSteps[step.Name] = step + } + for _, step := range serverConfig.Steps { + serverSteps[step.Name] = step + } + + // Compare steps by name + for name, cliStep := range cliSteps { + serverStep, exists := serverSteps[name] + assert.True(t, exists, "Step %s should exist in both configs", name) - assert.Equal(t, cliStep.Name, serverStep.Name, - "Step names should match for step %d", i) - // Compare StepConfig fields - assert.Equal(t, cliStep.Config.Input, serverStep.Config.Input, - "Input should match for step %s", cliStep.Name) - assert.Equal(t, cliStep.Config.Model, serverStep.Config.Model, - "Model should match for step %s", cliStep.Name) - assert.Equal(t, cliStep.Config.Action, serverStep.Config.Action, - "Action should match for step %s", cliStep.Name) - assert.Equal(t, cliStep.Config.Output, serverStep.Config.Output, - "Output should match for step %s", cliStep.Name) - assert.Equal(t, cliStep.Config.NextAction, serverStep.Config.NextAction, - "NextAction should match for step %s", cliStep.Name) + assert.Equal(t, cliStep.Config.Input, serverStep.Config.Input, + "Input should match for step %s", name) + assert.Equal(t, cliStep.Config.Model, serverStep.Config.Model, + "Model should match for step %s", name) + assert.Equal(t, cliStep.Config.Action, serverStep.Config.Action, + "Action should match for step %s", name) + assert.Equal(t, cliStep.Config.Output, serverStep.Config.Output, + "Output should match for step %s", name) + assert.Equal(t, cliStep.Config.NextAction, serverStep.Config.NextAction, + "NextAction should match for step %s", name) } // Verify both configs can be processed @@ -96,9 +104,9 @@ analyze_text: // Try parsing directly into DSLConfig (the old way that caused the bug) var dslConfig processor.DSLConfig err := yaml.Unmarshal(yamlContent, &dslConfig) - + // This should result in a DSLConfig with no steps assert.NoError(t, err, "Parsing should not error") - assert.Empty(t, dslConfig.Steps, + assert.Empty(t, dslConfig.Steps, "Direct parsing into DSLConfig should result in no steps due to YAML structure mismatch") } diff --git a/utils/server/provider_handlers.go b/utils/server/provider_handlers.go new file mode 100644 index 0000000..f570f26 --- /dev/null +++ b/utils/server/provider_handlers.go @@ -0,0 +1,242 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/kris-hansen/comanda/utils/config" + "github.com/kris-hansen/comanda/utils/models" +) + +// handleGetProviders returns the list of configured providers and their models +func (s *Server) handleGetProviders(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if !checkAuth(s.config, w, r) { + return + } + + providers := []ProviderInfo{} + + // Get provider instances + providerList := []struct { + name string + provider models.Provider + }{ + {"openai", models.NewOpenAIProvider()}, + {"anthropic", models.NewAnthropicProvider()}, + {"google", models.NewGoogleProvider()}, + {"xai", models.NewXAIProvider()}, + {"ollama", models.NewOllamaProvider()}, + } + + // Get provider configurations + for _, p := range providerList { + if p.provider != nil { + if provider, err := s.envConfig.GetProviderConfig(p.name); err == nil { + providers = append(providers, ProviderInfo{ + Name: p.name, + Models: getModelNames(provider.Models), + Enabled: provider.APIKey != "", + }) + } + } + } + + json.NewEncoder(w).Encode(ProviderListResponse{ + Success: true, + Providers: providers, + }) +} + +// handleValidateProvider validates a provider's API key +func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if !checkAuth(s.config, w, r) { + return + } + + var req ProviderRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + config.VerboseLog("Error decoding request: %v", err) + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Invalid request format", + }) + return + } + + // Create provider instance + var provider models.Provider + switch req.Name { + case "openai": + provider = models.NewOpenAIProvider() + case "anthropic": + provider = models.NewAnthropicProvider() + case "google": + provider = models.NewGoogleProvider() + case "xai": + provider = models.NewXAIProvider() + case "ollama": + provider = models.NewOllamaProvider() + default: + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": fmt.Sprintf("Unknown provider: %s", req.Name), + }) + return + } + + // Validate API key + if err := provider.Configure(req.APIKey); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": fmt.Sprintf("Invalid API key: %v", err), + }) + return + } + + json.NewEncoder(w).Encode(map[string]string{ + "message": "API key is valid", + }) +} + +// getModelNames extracts model names from config.Model slice +func getModelNames(models []config.Model) []string { + names := make([]string, len(models)) + for i, model := range models { + names[i] = model.Name + } + return names +} + +// handleUpdateProvider handles adding/updating a provider configuration +func (s *Server) handleUpdateProvider(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if !checkAuth(s.config, w, r) { + return + } + + var req ProviderRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + config.VerboseLog("Error decoding request: %v", err) + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Invalid request format", + }) + return + } + + // Get existing provider or create new one + provider, err := s.envConfig.GetProviderConfig(req.Name) + if err != nil { + // Provider doesn't exist, create new one + provider = &config.Provider{ + Models: make([]config.Model, 0), + } + s.envConfig.AddProvider(req.Name, *provider) + } + + // Update API key if provided + if req.APIKey != "" { + if err := s.envConfig.UpdateAPIKey(req.Name, req.APIKey); err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": fmt.Sprintf("Error updating API key: %v", err), + }) + return + } + + // Configure the provider with the new API key + var provider models.Provider + switch req.Name { + case "openai": + provider = models.NewOpenAIProvider() + case "anthropic": + provider = models.NewAnthropicProvider() + case "google": + provider = models.NewGoogleProvider() + case "xai": + provider = models.NewXAIProvider() + case "ollama": + provider = models.NewOllamaProvider() + default: + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": fmt.Sprintf("Unknown provider: %s", req.Name), + }) + return + } + + if provider != nil { + if err := provider.Configure(req.APIKey); err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": fmt.Sprintf("Error configuring provider: %v", err), + }) + return + } + } + } + + // Update models if provided + if len(req.Models) > 0 { + for _, modelName := range req.Models { + model := config.Model{ + Name: modelName, + Modes: []config.ModelMode{config.TextMode}, // Default to text mode + } + if err := s.envConfig.AddModelToProvider(req.Name, model); err != nil { + config.VerboseLog("Error adding model %s: %v", modelName, err) + // Continue with other models + } + } + } + + // Save the updated configuration + if err := config.SaveEnvConfig(config.GetEnvPath(), s.envConfig); err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": fmt.Sprintf("Error saving configuration: %v", err), + }) + return + } + + json.NewEncoder(w).Encode(map[string]string{ + "message": fmt.Sprintf("Provider %s updated successfully", req.Name), + }) +} + +// handleDeleteProvider handles removing a provider configuration +func (s *Server) handleDeleteProvider(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if !checkAuth(s.config, w, r) { + return + } + + // Get provider name from URL path + providerName := strings.TrimPrefix(r.URL.Path, "/providers/") + + // Remove provider from configuration + if s.envConfig.Providers != nil { + delete(s.envConfig.Providers, providerName) + } + + // Save the updated configuration + if err := config.SaveEnvConfig(config.GetEnvPath(), s.envConfig); err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": fmt.Sprintf("Error saving configuration: %v", err), + }) + return + } + + json.NewEncoder(w).Encode(map[string]string{ + "message": fmt.Sprintf("Provider %s removed successfully", providerName), + }) +} diff --git a/utils/server/server.go b/utils/server/server.go index 345417f..6c56bad 100644 --- a/utils/server/server.go +++ b/utils/server/server.go @@ -6,12 +6,87 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" "github.com/kris-hansen/comanda/utils/config" - "github.com/kris-hansen/comanda/utils/fileutil" ) +// Server represents the HTTP server +type Server struct { + mux *http.ServeMux + config *ServerConfig + envConfig *config.EnvConfig +} + +// validatePath ensures a path is relative and within the data directory +func (s *Server) validatePath(path string) (string, error) { + // Handle empty path by using default filename + if path == "" || path == "." { + path = "file.txt" + } + + // Initial validation before any path manipulation + if filepath.IsAbs(path) { + return "", fmt.Errorf("absolute paths are not allowed") + } + + // Normalize path separators to forward slashes for consistent checking + normalizedPath := filepath.ToSlash(path) + + // Check for various path traversal patterns + traversalPatterns := []string{ + "../", "/..", "../", "..\\", "\\..", + "/../", "\\..\\", "/../../", "\\..\\..\\", + } + for _, pattern := range traversalPatterns { + if strings.Contains(normalizedPath, pattern) { + return "", fmt.Errorf("path attempts to escape data directory") + } + } + + // Get absolute path of data directory + absDataDir, err := filepath.Abs(s.config.DataDir) + if err != nil { + return "", fmt.Errorf("invalid data directory path") + } + + // Join with data directory and clean the path + fullPath := filepath.Clean(filepath.Join(s.config.DataDir, path)) + + // Get absolute path of the target file + absPath, err := filepath.Abs(fullPath) + if err != nil { + return "", fmt.Errorf("invalid path") + } + + // Check if the path is within the data directory + if !strings.HasPrefix(absPath, absDataDir+string(os.PathSeparator)) { + return "", fmt.Errorf("path attempts to escape data directory") + } + + // Check if the path is the data directory itself + if absPath == absDataDir { + return "", fmt.Errorf("invalid path") + } + + // Get relative path and check for traversal attempts + relPath, err := filepath.Rel(s.config.DataDir, fullPath) + if err != nil { + return "", fmt.Errorf("invalid path") + } + + // Check each path component + components := strings.Split(filepath.ToSlash(relPath), "/") + for _, comp := range components { + if comp == ".." || comp == "." || strings.Contains(comp, "..") { + return "", fmt.Errorf("path attempts to escape data directory") + } + } + + return fullPath, nil +} + // New creates a new HTTP server with the given configuration func New(envConfig *config.EnvConfig) (*http.Server, error) { // Get server configuration @@ -33,10 +108,30 @@ func New(envConfig *config.EnvConfig) (*http.Server, error) { Enabled: serverConfig.Enabled, } - mux := http.NewServeMux() + s := &Server{ + mux: http.NewServeMux(), + config: srvConfig, + envConfig: envConfig, + } + + // Register routes + s.routes() + server := &http.Server{ + Addr: fmt.Sprintf(":%d", srvConfig.Port), + Handler: s.mux, + ReadTimeout: 30 * time.Second, + WriteTimeout: 120 * time.Second, + IdleTimeout: 120 * time.Second, + } + + return server, nil +} + +// routes sets up the server routes +func (s *Server) routes() { // Health check endpoint - mux.HandleFunc("/health", logRequest(func(w http.ResponseWriter, r *http.Request) { + s.mux.HandleFunc("/health", logRequest(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(HealthResponse{ Status: "ok", @@ -44,83 +139,63 @@ func New(envConfig *config.EnvConfig) (*http.Server, error) { }) })) - // List files endpoint - mux.HandleFunc("/list", logRequest(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") + // File operations + s.mux.HandleFunc("/list", logRequest(s.handleListFiles)) + s.mux.HandleFunc("/files", logRequest(s.handleFileOperation)) + s.mux.HandleFunc("/files/bulk", logRequest(s.handleBulkFileOperation)) + s.mux.HandleFunc("/files/backup", logRequest(s.handleFileBackup)) + s.mux.HandleFunc("/files/restore", logRequest(s.handleFileRestore)) + + // Provider operations + s.mux.HandleFunc("/providers", logRequest(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.handleGetProviders(w, r) + case http.MethodPut: + s.handleUpdateProvider(w, r) + case http.MethodDelete: + s.handleDeleteProvider(w, r) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Method not allowed", + }) + } + })) - if !checkAuth(srvConfig, w, r) { + // Provider validation + s.mux.HandleFunc("/providers/validate", logRequest(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) return } + s.handleValidateProvider(w, r) + })) - config.VerboseLog("Listing files in data directory") - config.DebugLog("Scanning directory: %s", srvConfig.DataDir) - - files, err := filepath.Glob(filepath.Join(srvConfig.DataDir, "*.yaml")) - if err != nil { - config.VerboseLog("Error listing files: %v", err) - config.DebugLog("Glob error: %v", err) - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(ListResponse{ - Success: false, - Error: fmt.Sprintf("Error listing files: %v", err), - }) + // Environment operations + s.mux.HandleFunc("/env/encrypt", logRequest(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) return } + s.handleEncryptEnv(w, r) + })) - var fileInfos []YAMLFileInfo - for _, file := range files { - relFile, err := filepath.Rel(srvConfig.DataDir, file) - if err != nil { - config.DebugLog("Error getting relative path for %s: %v", file, err) - continue - } - - config.DebugLog("Processing file: %s", relFile) - - // Read and parse YAML with size check - yamlContent, err := fileutil.SafeReadFile(file) - if err != nil { - config.DebugLog("Error reading file %s: %v", file, err) - continue - } - - methods := "GET" - if hasStdinInput(yamlContent) { - methods = "POST" - } - - fileInfos = append(fileInfos, YAMLFileInfo{ - Name: relFile, - Methods: methods, - }) + s.mux.HandleFunc("/env/decrypt", logRequest(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return } - - config.VerboseLog("Found %d YAML files", len(fileInfos)) - config.DebugLog("File list complete: %v", fileInfos) - - json.NewEncoder(w).Encode(ListResponse{ - Success: true, - Files: fileInfos, - }) + s.handleDecryptEnv(w, r) })) // Process endpoint - mux.HandleFunc("/process", logRequest(func(w http.ResponseWriter, r *http.Request) { - if !checkAuth(srvConfig, w, r) { + s.mux.HandleFunc("/process", logRequest(func(w http.ResponseWriter, r *http.Request) { + if !checkAuth(s.config, w, r) { return } - handleProcess(w, r, srvConfig, envConfig) + handleProcess(w, r, s.config, s.envConfig) })) - - server := &http.Server{ - Addr: fmt.Sprintf(":%d", srvConfig.Port), - Handler: mux, - ReadTimeout: 30 * time.Second, - WriteTimeout: 120 * time.Second, - IdleTimeout: 120 * time.Second, - } - - return server, nil } // Run creates and starts the HTTP server with the given configuration diff --git a/utils/server/types.go b/utils/server/types.go index 5bff495..a6b1d1e 100644 --- a/utils/server/types.go +++ b/utils/server/types.go @@ -4,8 +4,18 @@ import ( "io" "net/http" "strings" + "time" ) +// ServerConfig holds the configuration for the HTTP server +type ServerConfig struct { + Port int `json:"port"` + DataDir string `json:"dataDir"` + BearerToken string `json:"bearerToken,omitempty"` + Enabled bool `json:"enabled"` +} + +// ProcessResponse represents the response for process operations type ProcessResponse struct { Success bool `json:"success"` Message string `json:"message,omitempty"` @@ -13,20 +23,109 @@ type ProcessResponse struct { Output string `json:"output,omitempty"` } +// HealthResponse represents the health check response type HealthResponse struct { Status string `json:"status"` Timestamp string `json:"timestamp"` } -type YAMLFileInfo struct { - Name string `json:"name"` - Methods string `json:"methods"` // "GET" or "POST" +// FileInfo represents detailed information about a file +type FileInfo struct { + Name string `json:"name"` + Path string `json:"path"` + Size int64 `json:"size"` + IsDir bool `json:"isDir"` + CreatedAt time.Time `json:"createdAt"` + ModifiedAt time.Time `json:"modifiedAt"` + Methods string `json:"methods,omitempty"` +} + +// FileRequest represents a request to create/edit a file +type FileRequest struct { + Path string `json:"path"` + Content string `json:"content"` +} + +// FileResponse represents a response for file operations +type FileResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` + File FileInfo `json:"file,omitempty"` } +// ListResponse represents the response for file listing type ListResponse struct { - Success bool `json:"success"` - Files []YAMLFileInfo `json:"files"` - Error string `json:"error,omitempty"` + Success bool `json:"success"` + Files []FileInfo `json:"files"` + Error string `json:"error,omitempty"` +} + +// BulkFileRequest represents a request for bulk file operations +type BulkFileRequest struct { + Files []FileRequest `json:"files"` +} + +// BulkFileResponse represents a response for bulk file operations +type BulkFileResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` + Results []FileResult `json:"results,omitempty"` +} + +// FileResult represents the result of a single file operation +type FileResult struct { + Path string `json:"path"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +// BackupResponse represents a response for backup operations +type BackupResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` + Filename string `json:"filename,omitempty"` +} + +// RestoreRequest represents a request for restore operations +type RestoreRequest struct { + Backup string `json:"backup"` +} + +// ProviderInfo represents information about a provider +type ProviderInfo struct { + Name string `json:"name"` + Models []string `json:"models"` + Enabled bool `json:"enabled"` +} + +// ProviderListResponse represents the response for provider listing +type ProviderListResponse struct { + Success bool `json:"success"` + Providers []ProviderInfo `json:"providers"` + Error string `json:"error,omitempty"` +} + +// ProviderRequest represents a request to modify a provider +type ProviderRequest struct { + Name string `json:"name"` + APIKey string `json:"apiKey"` + Models []string `json:"models,omitempty"` + Enabled bool `json:"enabled"` +} + +// EnvironmentRequest represents a request for environment operations +type EnvironmentRequest struct { + Password string `json:"password"` +} + +// EnvironmentResponse represents a response for environment operations +type EnvironmentResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` } // responseWriter wraps http.ResponseWriter to capture the status code @@ -54,22 +153,9 @@ type filteringWriter struct { } func (w *filteringWriter) Write(p []byte) (n int, err error) { - // Convert to string for easier handling s := string(p) - - // Check if this is a debug or verbose message if strings.HasPrefix(s, "[DEBUG]") || strings.HasPrefix(s, "[VERBOSE]") { return w.debug.Write(p) } - - // This is actual output, write to both return w.output.Write(p) } - -// ServerConfig holds the configuration for the HTTP server -type ServerConfig struct { - Port int - DataDir string - BearerToken string - Enabled bool -}