diff --git a/CLAUDE.md b/CLAUDE.md index e594f6a..bbecfea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,11 +75,13 @@ banatie-service/ - **Route Handling**: - `src/routes/bootstrap.ts` - Bootstrap initial master key (one-time) - `src/routes/admin/keys.ts` - API key management (master key required) - - `src/routes/generate.ts` - Image generation endpoint (API key required) + - `src/routes/textToImage.ts` - Image generation endpoint (API key required) + - `src/routes/upload.ts` - File upload endpoint (project key required) + - `src/routes/images.ts` - Image serving endpoint (public) ### Middleware Stack (API Service) -- `src/middleware/upload.ts` - Multer configuration for file uploads (max 3 files, 5MB each) +- `src/middleware/upload.ts` - Multer configuration for file uploads (max 3 reference images, 5MB each; single file upload for `/api/upload`) - `src/middleware/validation.ts` - Express-validator for request validation - `src/middleware/errorHandler.ts` - Centralized error handling and 404 responses - `src/middleware/auth/validateApiKey.ts` - API key authentication @@ -214,6 +216,7 @@ Located at `apps/api-service/.env` - used ONLY when running `pnpm dev:api` local ### Protected Endpoints (API Key Required) - `POST /api/text-to-image` - Generate images from text only (JSON) +- `POST /api/upload` - Upload single image file to project storage - `GET /api/images` - List generated images **Authentication**: All protected endpoints require `X-API-Key` header @@ -250,6 +253,11 @@ curl -X POST http://localhost:3000/api/text-to-image \ "prompt": "a sunset", "filename": "test_image" }' + +# File upload with project key +curl -X POST http://localhost:3000/api/upload \ + -H "X-API-Key: YOUR_PROJECT_KEY" \ + -F "file=@image.png" ``` ### Key Management diff --git a/apps/api-service/src/app.ts b/apps/api-service/src/app.ts index e4031f4..3730df7 100644 --- a/apps/api-service/src/app.ts +++ b/apps/api-service/src/app.ts @@ -4,6 +4,7 @@ import { config } from 'dotenv'; import { Config } from './types/api'; import { textToImageRouter } from './routes/textToImage'; import { imagesRouter } from './routes/images'; +import { uploadRouter } from './routes/upload'; import bootstrapRoutes from './routes/bootstrap'; import adminKeysRoutes from './routes/admin/keys'; import { errorHandler, notFoundHandler } from './middleware/errorHandler'; @@ -118,6 +119,7 @@ export const createApp = (): Application => { // Protected API routes (require valid API key) app.use('/api', textToImageRouter); app.use('/api', imagesRouter); + app.use('/api', uploadRouter); // Error handling middleware (must be last) app.use(notFoundHandler); diff --git a/apps/api-service/src/middleware/upload.ts b/apps/api-service/src/middleware/upload.ts index d8e1897..599cc63 100644 --- a/apps/api-service/src/middleware/upload.ts +++ b/apps/api-service/src/middleware/upload.ts @@ -38,6 +38,9 @@ export const upload = multer({ // Middleware for handling reference images export const uploadReferenceImages: RequestHandler = upload.array('referenceImages', MAX_FILES); +// Middleware for handling single image upload +export const uploadSingleImage: RequestHandler = upload.single('file'); + // Error handler for multer errors export const handleUploadErrors = (error: any, _req: Request, res: any, next: any) => { if (error instanceof multer.MulterError) { diff --git a/apps/api-service/src/routes/upload.ts b/apps/api-service/src/routes/upload.ts new file mode 100644 index 0000000..9efbe37 --- /dev/null +++ b/apps/api-service/src/routes/upload.ts @@ -0,0 +1,109 @@ +import { Response, Router } from 'express'; +import type { Router as RouterType } from 'express'; +import { StorageFactory } from '../services/StorageFactory'; +import { asyncHandler } from '../middleware/errorHandler'; +import { validateApiKey } from '../middleware/auth/validateApiKey'; +import { requireProjectKey } from '../middleware/auth/requireProjectKey'; +import { rateLimitByApiKey } from '../middleware/auth/rateLimiter'; +import { uploadSingleImage, handleUploadErrors } from '../middleware/upload'; +import { UploadFileResponse } from '../types/api'; + +export const uploadRouter: RouterType = Router(); + +/** + * POST /api/upload - Upload a single image file + */ +uploadRouter.post( + '/upload', + // Authentication middleware + validateApiKey, + requireProjectKey, + rateLimitByApiKey, + + // File upload middleware + uploadSingleImage, + handleUploadErrors, + + // Main handler + asyncHandler(async (req: any, res: Response) => { + const timestamp = new Date().toISOString(); + const requestId = req.requestId; + + // Check if file was provided + if (!req.file) { + const errorResponse: UploadFileResponse = { + success: false, + message: 'File upload failed', + error: 'No file provided', + }; + return res.status(400).json(errorResponse); + } + + // Extract org/project slugs from validated API key + const orgId = req.apiKey?.organizationSlug || 'default'; + const projectId = req.apiKey?.projectSlug!; // Guaranteed by requireProjectKey middleware + + console.log( + `[${timestamp}] [${requestId}] Starting file upload for org:${orgId}, project:${projectId}`, + ); + + const file = req.file; + + try { + // Initialize storage service + const storageService = await StorageFactory.getInstance(); + + // Upload file to MinIO in 'uploads' category + console.log( + `[${timestamp}] [${requestId}] Uploading file: ${file.originalname} (${file.size} bytes)`, + ); + + const uploadResult = await storageService.uploadFile( + orgId, + projectId, + 'uploads', + file.originalname, + file.buffer, + file.mimetype, + ); + + if (!uploadResult.success) { + const errorResponse: UploadFileResponse = { + success: false, + message: 'File upload failed', + error: uploadResult.error || 'Storage service error', + }; + return res.status(500).json(errorResponse); + } + + // Prepare success response + const successResponse: UploadFileResponse = { + success: true, + message: 'File uploaded successfully', + data: { + filename: uploadResult.filename, + originalName: file.originalname, + path: uploadResult.path, + url: uploadResult.url, + size: uploadResult.size, + contentType: uploadResult.contentType, + uploadedAt: timestamp, + }, + }; + + console.log(`[${timestamp}] [${requestId}] File uploaded successfully: ${uploadResult.url}`); + + return res.status(200).json(successResponse); + } catch (error) { + console.error(`[${timestamp}] [${requestId}] Unhandled error in upload endpoint:`, error); + + const errorResponse: UploadFileResponse = { + success: false, + message: 'File upload failed', + error: error instanceof Error ? error.message : 'Unknown error occurred', + }; + + return res.status(500).json(errorResponse); + } + }), +); diff --git a/apps/api-service/src/types/api.ts b/apps/api-service/src/types/api.ts index 8721e9e..6b4983f 100644 --- a/apps/api-service/src/types/api.ts +++ b/apps/api-service/src/types/api.ts @@ -166,6 +166,29 @@ export interface EnhancedGenerateImageRequest extends GenerateImageRequest { }; } +// Upload file types +export interface UploadFileRequest { + metadata?: { + description?: string; + tags?: string[]; + }; +} + +export interface UploadFileResponse { + success: boolean; + message: string; + data?: { + filename: string; + originalName: string; + path: string; + url: string; + size: number; + contentType: string; + uploadedAt: string; + }; + error?: string; +} + // Environment configuration export interface Config { port: number; diff --git a/docs/api/README.md b/docs/api/README.md index 647c8b3..a757415 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -58,6 +58,7 @@ All authenticated endpoints (those requiring API keys) are rate limited: - **Applies to:** - `POST /api/generate` - `POST /api/text-to-image` + - `POST /api/upload` - `POST /api/enhance` - **Not rate limited:** - Public endpoints (`GET /health`, `GET /api/info`) @@ -88,6 +89,7 @@ Rate limit information included in response headers: | `/api/admin/keys/:keyId` | DELETE | Master Key | No | Revoke API key | | `/api/generate` | POST | API Key | 100/hour | Generate images with files | | `/api/text-to-image` | POST | API Key | 100/hour | Generate images (JSON only) | +| `/api/upload` | POST | API Key | 100/hour | Upload single image file | | `/api/enhance` | POST | API Key | 100/hour | Enhance text prompts | | `/api/images/*` | GET | None | No | Serve generated images | @@ -438,6 +440,89 @@ curl -X POST http://localhost:3000/api/text-to-image \ --- +### Upload File + +#### `POST /api/upload` + +Upload a single image file to project storage. + +**Authentication:** Project API key required (master keys not allowed) +**Rate Limit:** 100 requests per hour per API key + +**Content-Type:** `multipart/form-data` + +**Parameters:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `file` | file | Yes | Single image file (PNG, JPEG, JPG, WebP) | +| `metadata` | JSON | No | Optional metadata (description, tags) | + +**File Specifications:** +- **Max file size:** 5MB +- **Supported formats:** PNG, JPEG, JPG, WebP +- **Max files per request:** 1 + +**Example Request:** +```bash +curl -X POST http://localhost:3000/api/upload \ + -H "X-API-Key: bnt_your_project_key_here" \ + -F "file=@image.png" \ + -F 'metadata={"description":"Product photo","tags":["demo","test"]}' +``` + +**Success Response (200):** +```json +{ + "success": true, + "message": "File uploaded successfully", + "data": { + "filename": "image-1728561234567-a1b2c3.png", + "originalName": "image.png", + "path": "org-slug/project-slug/uploads/image-1728561234567-a1b2c3.png", + "url": "http://localhost:3000/api/images/org-slug/project-slug/uploads/image-1728561234567-a1b2c3.png", + "size": 123456, + "contentType": "image/png", + "uploadedAt": "2025-10-10T12:00:00.000Z" + } +} +``` + +**Error Response (400 - No file):** +```json +{ + "success": false, + "message": "File upload failed", + "error": "No file provided" +} +``` + +**Error Response (400 - Invalid file type):** +```json +{ + "success": false, + "message": "File validation failed", + "error": "Unsupported file type: image/gif. Allowed: PNG, JPEG, WebP" +} +``` + +**Error Response (400 - File too large):** +```json +{ + "success": false, + "message": "File upload failed", + "error": "File too large. Maximum size: 5MB" +} +``` + +**Storage Details:** +- Files are stored in MinIO under: `{orgSlug}/{projectSlug}/uploads/` +- Filenames are automatically made unique with timestamp and random suffix +- Original filename is preserved in response +- Uploaded files can be accessed via the returned URL + +--- + ### Enhance Prompt #### `POST /api/enhance` @@ -520,7 +605,7 @@ Enhance and optimize text prompts for better image generation results. ### Authentication Errors (401) - `"Missing API key"` - No X-API-Key header provided - `"Invalid API key"` - The provided API key is invalid, expired, or revoked -- **Affected endpoints:** `/api/generate`, `/api/text-to-image`, `/api/enhance`, `/api/admin/*` +- **Affected endpoints:** `/api/generate`, `/api/text-to-image`, `/api/upload`, `/api/enhance`, `/api/admin/*` ### Authorization Errors (403) - `"Master key required"` - This endpoint requires a master API key (not project key) @@ -534,7 +619,7 @@ Enhance and optimize text prompts for better image generation results. ### Rate Limiting Errors (429) - `"Rate limit exceeded"` - Too many requests, retry after specified time -- **Applies to:** `/api/generate`, `/api/text-to-image`, `/api/enhance` +- **Applies to:** `/api/generate`, `/api/text-to-image`, `/api/upload`, `/api/enhance` - **Rate limit:** 100 requests per hour per API key - **Response includes:** `Retry-After` header with seconds until reset