Compare commits
No commits in common. "6944e6b750d433cb3dda8251b55f0b31b363198b" and "1ea8492e21746ed9a6cdfa75bf457264d0764504" have entirely different histories.
6944e6b750
...
1ea8492e21
31
.mcp.json
31
.mcp.json
|
|
@ -1,34 +1,29 @@
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"context7": {
|
"context7": {
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@upstash/context7-mcp",
|
||||||
|
"--api-key",
|
||||||
|
"ctx7sk-48cb1995-935a-4cc5-b9b0-535d600ea5e6"
|
||||||
|
],
|
||||||
|
"env": {}
|
||||||
|
},
|
||||||
|
"brave-search": {
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": [
|
"args": [
|
||||||
"-y",
|
"-y",
|
||||||
"@upstash/context7-mcp",
|
"@modelcontextprotocol/server-brave-search"
|
||||||
"--api-key",
|
|
||||||
"ctx7sk-48cb1995-935a-4cc5-b9b0-535d600ea5e6"
|
|
||||||
],
|
],
|
||||||
"env": {}
|
|
||||||
},
|
|
||||||
"brave-search": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
|
|
||||||
"env": {
|
"env": {
|
||||||
"BRAVE_API_KEY": "BSAcRGGikEzY4B2j3NZ8Qy5NYh9l4HZ"
|
"BRAVE_API_KEY": "BSAcRGGikEzY4B2j3NZ8Qy5NYh9l4HZ"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"postgres": {
|
"postgres": {
|
||||||
"command": "docker",
|
"command": "docker",
|
||||||
"args": [
|
"args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=unrestricted"],
|
||||||
"run",
|
|
||||||
"-i",
|
|
||||||
"--rm",
|
|
||||||
"-e",
|
|
||||||
"DATABASE_URI",
|
|
||||||
"crystaldba/postgres-mcp",
|
|
||||||
"--access-mode=unrestricted"
|
|
||||||
],
|
|
||||||
"env": {
|
"env": {
|
||||||
"DATABASE_URI": "postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db"
|
"DATABASE_URI": "postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
# Dependencies
|
|
||||||
node_modules
|
|
||||||
.pnpm-store
|
|
||||||
|
|
||||||
# Build outputs
|
|
||||||
dist
|
|
||||||
build
|
|
||||||
.next
|
|
||||||
out
|
|
||||||
.turbo
|
|
||||||
|
|
||||||
# Environment files
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
|
|
||||||
# Data and storage
|
|
||||||
data/
|
|
||||||
data/postgres/**
|
|
||||||
*.sql
|
|
||||||
*.db
|
|
||||||
*.sqlite
|
|
||||||
|
|
||||||
# Lock files
|
|
||||||
pnpm-lock.yaml
|
|
||||||
package-lock.json
|
|
||||||
yarn.lock
|
|
||||||
|
|
||||||
# Coverage and test reports
|
|
||||||
coverage
|
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# Editor files
|
|
||||||
.vscode
|
|
||||||
.idea
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
|
|
||||||
# OS files
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Generated files
|
|
||||||
*.min.js
|
|
||||||
*.min.css
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
{
|
|
||||||
"semi": true,
|
|
||||||
"trailingComma": "all",
|
|
||||||
"singleQuote": true,
|
|
||||||
"printWidth": 100,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"useTabs": false,
|
|
||||||
"arrowParens": "always",
|
|
||||||
"endOfLine": "lf"
|
|
||||||
}
|
|
||||||
19
CLAUDE.md
19
CLAUDE.md
|
|
@ -115,7 +115,6 @@ Shared Drizzle ORM package used by API service and future apps:
|
||||||
- **Configuration**: `drizzle.config.ts` - Drizzle Kit configuration
|
- **Configuration**: `drizzle.config.ts` - Drizzle Kit configuration
|
||||||
|
|
||||||
Key table: `api_keys`
|
Key table: `api_keys`
|
||||||
|
|
||||||
- Stores hashed API keys (SHA-256)
|
- Stores hashed API keys (SHA-256)
|
||||||
- Two types: `master` (admin, never expires) and `project` (90-day expiration)
|
- Two types: `master` (admin, never expires) and `project` (90-day expiration)
|
||||||
- Soft delete via `is_active` flag
|
- Soft delete via `is_active` flag
|
||||||
|
|
@ -128,7 +127,6 @@ Key table: `api_keys`
|
||||||
### Root `.env` (Docker Compose Infrastructure)
|
### Root `.env` (Docker Compose Infrastructure)
|
||||||
|
|
||||||
Used by Docker Compose services (MinIO, Postgres, API container). Key differences from local:
|
Used by Docker Compose services (MinIO, Postgres, API container). Key differences from local:
|
||||||
|
|
||||||
- `DATABASE_URL=postgresql://banatie_user:banatie_secure_password@postgres:5432/banatie_db` (Docker network hostname)
|
- `DATABASE_URL=postgresql://banatie_user:banatie_secure_password@postgres:5432/banatie_db` (Docker network hostname)
|
||||||
- `MINIO_ENDPOINT=minio:9000` (Docker network hostname)
|
- `MINIO_ENDPOINT=minio:9000` (Docker network hostname)
|
||||||
- `MINIO_ROOT_USER` and `MINIO_ROOT_PASSWORD` - MinIO admin credentials
|
- `MINIO_ROOT_USER` and `MINIO_ROOT_PASSWORD` - MinIO admin credentials
|
||||||
|
|
@ -137,7 +135,6 @@ Used by Docker Compose services (MinIO, Postgres, API container). Key difference
|
||||||
### API Service `.env` (Local Development Only)
|
### API Service `.env` (Local Development Only)
|
||||||
|
|
||||||
Located at `apps/api-service/.env` - used ONLY when running `pnpm dev:api` locally:
|
Located at `apps/api-service/.env` - used ONLY when running `pnpm dev:api` locally:
|
||||||
|
|
||||||
- `DATABASE_URL=postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db` (port-forwarded)
|
- `DATABASE_URL=postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db` (port-forwarded)
|
||||||
- `MINIO_ENDPOINT=localhost:9000` (port-forwarded)
|
- `MINIO_ENDPOINT=localhost:9000` (port-forwarded)
|
||||||
- **NOTE**: This file is excluded from Docker builds (see Dockerfile.mono)
|
- **NOTE**: This file is excluded from Docker builds (see Dockerfile.mono)
|
||||||
|
|
@ -200,20 +197,19 @@ Located at `apps/api-service/.env` - used ONLY when running `pnpm dev:api` local
|
||||||
## API Endpoints (API Service)
|
## API Endpoints (API Service)
|
||||||
|
|
||||||
### Public Endpoints (No Authentication)
|
### Public Endpoints (No Authentication)
|
||||||
|
|
||||||
- `GET /health` - Health check with uptime and status
|
- `GET /health` - Health check with uptime and status
|
||||||
- `GET /api/info` - API information and limits
|
- `GET /api/info` - API information and limits
|
||||||
- `POST /api/bootstrap/initial-key` - Create first master key (one-time only)
|
- `POST /api/bootstrap/initial-key` - Create first master key (one-time only)
|
||||||
|
|
||||||
### Admin Endpoints (Master Key Required)
|
### Admin Endpoints (Master Key Required)
|
||||||
|
|
||||||
- `POST /api/admin/keys` - Create new API keys (master or project)
|
- `POST /api/admin/keys` - Create new API keys (master or project)
|
||||||
- `GET /api/admin/keys` - List all API keys
|
- `GET /api/admin/keys` - List all API keys
|
||||||
- `DELETE /api/admin/keys/:keyId` - Revoke an API key
|
- `DELETE /api/admin/keys/:keyId` - Revoke an API key
|
||||||
|
|
||||||
### Protected Endpoints (API Key Required)
|
### Protected Endpoints (API Key Required)
|
||||||
|
- `POST /api/generate` - Generate images from text + optional reference images
|
||||||
- `POST /api/text-to-image` - Generate images from text only (JSON)
|
- `POST /api/text-to-image` - Generate images from text only (JSON)
|
||||||
|
- `POST /api/enhance` - Enhance and optimize text prompts
|
||||||
- `GET /api/images` - List generated images
|
- `GET /api/images` - List generated images
|
||||||
|
|
||||||
**Authentication**: All protected endpoints require `X-API-Key` header
|
**Authentication**: All protected endpoints require `X-API-Key` header
|
||||||
|
|
@ -224,11 +220,9 @@ Located at `apps/api-service/.env` - used ONLY when running `pnpm dev:api` local
|
||||||
|
|
||||||
1. **Start Services**: `docker compose up -d`
|
1. **Start Services**: `docker compose up -d`
|
||||||
2. **Create Master Key**:
|
2. **Create Master Key**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:3000/api/bootstrap/initial-key
|
curl -X POST http://localhost:3000/api/bootstrap/initial-key
|
||||||
```
|
```
|
||||||
|
|
||||||
Save the returned key securely!
|
Save the returned key securely!
|
||||||
|
|
||||||
3. **Create Project Key** (for testing):
|
3. **Create Project Key** (for testing):
|
||||||
|
|
@ -243,13 +237,10 @@ Located at `apps/api-service/.env` - used ONLY when running `pnpm dev:api` local
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Image generation with project key
|
# Image generation with project key
|
||||||
curl -X POST http://localhost:3000/api/text-to-image \
|
curl -X POST http://localhost:3000/api/generate \
|
||||||
-H "X-API-Key: YOUR_PROJECT_KEY" \
|
-H "X-API-Key: YOUR_PROJECT_KEY" \
|
||||||
-H "Content-Type: application/json" \
|
-F "prompt=a sunset" \
|
||||||
-d '{
|
-F "filename=test_image"
|
||||||
"prompt": "a sunset",
|
|
||||||
"filename": "test_image"
|
|
||||||
}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key Management
|
### Key Management
|
||||||
|
|
|
||||||
41
README.md
41
README.md
|
|
@ -19,28 +19,24 @@ banatie-service/
|
||||||
## Applications
|
## Applications
|
||||||
|
|
||||||
### 🚀 API Service (`apps/api-service`)
|
### 🚀 API Service (`apps/api-service`)
|
||||||
|
|
||||||
- **Port**: 3000
|
- **Port**: 3000
|
||||||
- **Tech**: Express.js, TypeScript, Gemini AI
|
- **Tech**: Express.js, TypeScript, Gemini AI
|
||||||
- **Purpose**: Core REST API for AI image generation
|
- **Purpose**: Core REST API for AI image generation
|
||||||
- **Features**: Image generation, file upload, rate limiting, logging
|
- **Features**: Image generation, file upload, rate limiting, logging
|
||||||
|
|
||||||
### 🌐 Landing Page (`apps/landing`)
|
### 🌐 Landing Page (`apps/landing`)
|
||||||
|
|
||||||
- **Port**: 3001
|
- **Port**: 3001
|
||||||
- **Tech**: Next.js 14, Tailwind CSS
|
- **Tech**: Next.js 14, Tailwind CSS
|
||||||
- **Purpose**: Public landing page with demo
|
- **Purpose**: Public landing page with demo
|
||||||
- **Features**: Service overview, image generation demo
|
- **Features**: Service overview, image generation demo
|
||||||
|
|
||||||
### 🏢 Studio (`apps/studio`)
|
### 🏢 Studio (`apps/studio`)
|
||||||
|
|
||||||
- **Port**: 3002
|
- **Port**: 3002
|
||||||
- **Tech**: Next.js 14, Supabase, Stripe
|
- **Tech**: Next.js 14, Supabase, Stripe
|
||||||
- **Purpose**: SaaS platform for paid users
|
- **Purpose**: SaaS platform for paid users
|
||||||
- **Features**: Authentication, billing, subscriptions, usage tracking
|
- **Features**: Authentication, billing, subscriptions, usage tracking
|
||||||
|
|
||||||
### 🔧 Admin (`apps/admin`)
|
### 🔧 Admin (`apps/admin`)
|
||||||
|
|
||||||
- **Port**: 3003
|
- **Port**: 3003
|
||||||
- **Tech**: Next.js 14, Dashboard components
|
- **Tech**: Next.js 14, Dashboard components
|
||||||
- **Purpose**: Service administration and monitoring
|
- **Purpose**: Service administration and monitoring
|
||||||
|
|
@ -49,7 +45,6 @@ banatie-service/
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js >= 18.0.0
|
- Node.js >= 18.0.0
|
||||||
- pnpm >= 8.0.0
|
- pnpm >= 8.0.0
|
||||||
- Docker & Docker Compose
|
- Docker & Docker Compose
|
||||||
|
|
@ -129,29 +124,29 @@ See individual app README files for specific environment variables.
|
||||||
|
|
||||||
## Services & Ports
|
## Services & Ports
|
||||||
|
|
||||||
| Service | Port | Description |
|
| Service | Port | Description |
|
||||||
| --------------- | ---- | ---------------- |
|
|---------|------|-------------|
|
||||||
| API Service | 3000 | Core REST API |
|
| API Service | 3000 | Core REST API |
|
||||||
| Landing Page | 3001 | Public website |
|
| Landing Page | 3001 | Public website |
|
||||||
| Studio Platform | 3002 | SaaS application |
|
| Studio Platform | 3002 | SaaS application |
|
||||||
| Admin Dashboard | 3003 | Administration |
|
| Admin Dashboard | 3003 | Administration |
|
||||||
| PostgreSQL | 5434 | Database |
|
| PostgreSQL | 5434 | Database |
|
||||||
| MinIO API | 9000 | Object storage |
|
| MinIO API | 9000 | Object storage |
|
||||||
| MinIO Console | 9001 | Storage admin |
|
| MinIO Console | 9001 | Storage admin |
|
||||||
|
|
||||||
## API Usage
|
## API Usage
|
||||||
|
|
||||||
### Generate Image
|
### Generate Image
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Text-to-image
|
# Basic text-to-image
|
||||||
curl -X POST http://localhost:3000/api/text-to-image \
|
curl -X POST http://localhost:3000/api/generate \
|
||||||
-H "Content-Type: application/json" \
|
-F "prompt=A magical forest with glowing mushrooms" \
|
||||||
-H "X-API-Key: YOUR_API_KEY" \
|
-F "filename=magical_forest"
|
||||||
-d '{
|
|
||||||
"prompt": "A magical forest with glowing mushrooms",
|
# With reference images
|
||||||
"filename": "magical_forest"
|
curl -X POST http://localhost:3000/api/generate \
|
||||||
}'
|
-F "prompt=Character in medieval armor like the reference" \
|
||||||
|
-F "referenceImages=@./reference.jpg"
|
||||||
```
|
```
|
||||||
|
|
||||||
See `apps/api-service/README.md` for detailed API documentation.
|
See `apps/api-service/README.md` for detailed API documentation.
|
||||||
|
|
@ -165,4 +160,4 @@ See `apps/api-service/README.md` for detailed API documentation.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License - see individual app directories for more details.
|
MIT License - see individual app directories for more details.
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
{
|
|
||||||
"extends": ["next/core-web-vitals", "prettier"],
|
|
||||||
"plugins": ["prettier"],
|
|
||||||
"rules": {
|
|
||||||
"prettier/prettier": "error"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -63,4 +63,4 @@ ADMIN_PASSWORD=secure_password
|
||||||
- [ ] Create backup/restore functionality
|
- [ ] Create backup/restore functionality
|
||||||
- [ ] Add alert system
|
- [ ] Add alert system
|
||||||
- [ ] Implement audit logging
|
- [ ] Implement audit logging
|
||||||
- [ ] Add API key management
|
- [ ] Add API key management
|
||||||
|
|
@ -11,6 +11,6 @@ const nextConfig = {
|
||||||
POSTGRES_URL: process.env.POSTGRES_URL,
|
POSTGRES_URL: process.env.POSTGRES_URL,
|
||||||
MINIO_ENDPOINT: process.env.MINIO_ENDPOINT,
|
MINIO_ENDPOINT: process.env.MINIO_ENDPOINT,
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
module.exports = nextConfig;
|
module.exports = nextConfig
|
||||||
|
|
@ -34,4 +34,4 @@
|
||||||
"node": ">=18.0.0",
|
"node": ">=18.0.0",
|
||||||
"pnpm": ">=8.0.0"
|
"pnpm": ">=8.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,16 +1,22 @@
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Banatie Admin - Service Administration',
|
title: 'Banatie Admin - Service Administration',
|
||||||
description: 'Administration dashboard for managing the Banatie AI image generation service',
|
description: 'Administration dashboard for managing the Banatie AI image generation service',
|
||||||
};
|
}
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className="min-h-screen bg-gray-100">
|
<body className="min-h-screen bg-gray-100">
|
||||||
<div className="min-h-screen">{children}</div>
|
<div className="min-h-screen">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -6,7 +6,9 @@ export default function AdminDashboard() {
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex justify-between h-16">
|
<div className="flex justify-between h-16">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Banatie Admin</h1>
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
Banatie Admin
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<span className="text-sm text-gray-500">System Status: Online</span>
|
<span className="text-sm text-gray-500">System Status: Online</span>
|
||||||
|
|
@ -19,6 +21,7 @@ export default function AdminDashboard() {
|
||||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||||
<div className="px-4 py-6 sm:px-0">
|
<div className="px-4 py-6 sm:px-0">
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
|
|
@ -30,8 +33,12 @@ export default function AdminDashboard() {
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-5 w-0 flex-1">
|
<div className="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt className="text-sm font-medium text-gray-500 truncate">API Requests</dt>
|
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||||
<dd className="text-lg font-medium text-gray-900">1,234</dd>
|
API Requests
|
||||||
|
</dt>
|
||||||
|
<dd className="text-lg font-medium text-gray-900">
|
||||||
|
1,234
|
||||||
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -51,7 +58,9 @@ export default function AdminDashboard() {
|
||||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||||
Images Generated
|
Images Generated
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-lg font-medium text-gray-900">567</dd>
|
<dd className="text-lg font-medium text-gray-900">
|
||||||
|
567
|
||||||
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -68,8 +77,12 @@ export default function AdminDashboard() {
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-5 w-0 flex-1">
|
<div className="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt className="text-sm font-medium text-gray-500 truncate">Active Users</dt>
|
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||||
<dd className="text-lg font-medium text-gray-900">89</dd>
|
Active Users
|
||||||
|
</dt>
|
||||||
|
<dd className="text-lg font-medium text-gray-900">
|
||||||
|
89
|
||||||
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -86,8 +99,12 @@ export default function AdminDashboard() {
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-5 w-0 flex-1">
|
<div className="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt className="text-sm font-medium text-gray-500 truncate">Errors (24h)</dt>
|
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||||
<dd className="text-lg font-medium text-gray-900">3</dd>
|
Errors (24h)
|
||||||
|
</dt>
|
||||||
|
<dd className="text-lg font-medium text-gray-900">
|
||||||
|
3
|
||||||
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -99,7 +116,9 @@ export default function AdminDashboard() {
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<div className="bg-white shadow rounded-lg">
|
<div className="bg-white shadow rounded-lg">
|
||||||
<div className="px-4 py-5 sm:p-6">
|
<div className="px-4 py-5 sm:p-6">
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">Service Status</h3>
|
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
|
||||||
|
Service Status
|
||||||
|
</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-gray-900">API Service</span>
|
<span className="text-sm font-medium text-gray-900">API Service</span>
|
||||||
|
|
@ -140,8 +159,8 @@ export default function AdminDashboard() {
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mt-2 text-sm text-blue-700">
|
<div className="mt-2 text-sm text-blue-700">
|
||||||
<p>
|
<p>
|
||||||
This admin dashboard is being developed to provide comprehensive monitoring
|
This admin dashboard is being developed to provide comprehensive
|
||||||
and management capabilities for the Banatie service.
|
monitoring and management capabilities for the Banatie service.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -151,5 +170,5 @@ export default function AdminDashboard() {
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -28,4 +28,4 @@
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|
@ -12,4 +12,4 @@ module.exports = {
|
||||||
quoteProps: 'as-needed',
|
quoteProps: 'as-needed',
|
||||||
jsxSingleQuote: true,
|
jsxSingleQuote: true,
|
||||||
proseWrap: 'preserve',
|
proseWrap: 'preserve',
|
||||||
};
|
};
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
# Network Error Detection
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This implementation adds intelligent network error detection to the Banatie API service. It follows best practices by **only checking connectivity when errors occur** (zero overhead on successful requests) and provides clear, actionable error messages to users.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### ✅ Lazy Validation
|
|
||||||
- Network checks **only trigger on failures**
|
|
||||||
- Zero performance overhead on successful requests
|
|
||||||
- Follows the fail-fast pattern
|
|
||||||
|
|
||||||
### ✅ Error Classification
|
|
||||||
Automatically detects and classifies:
|
|
||||||
- **DNS Resolution Failures** (`ENOTFOUND`, `EAI_AGAIN`)
|
|
||||||
- **Connection Timeouts** (`ETIMEDOUT`)
|
|
||||||
- **Connection Refused** (`ECONNREFUSED`)
|
|
||||||
- **Connection Resets** (`ECONNRESET`, `ENETUNREACH`)
|
|
||||||
- **Generic Network Errors** (fetch failed, etc.)
|
|
||||||
|
|
||||||
### ✅ Clear User Messages
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```
|
|
||||||
Gemini AI generation failed: exception TypeError: fetch failed sending request
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```
|
|
||||||
Network error: Unable to connect to Gemini API. Please check your internet connection and firewall settings.
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ Detailed Logging
|
|
||||||
|
|
||||||
Logs contain both user-friendly messages and technical details:
|
|
||||||
```
|
|
||||||
[NETWORK ERROR - DNS] DNS resolution failed: Unable to resolve Gemini API hostname | Technical: Error code: ENOTFOUND
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
### Core Utility
|
|
||||||
|
|
||||||
**Location:** `src/utils/NetworkErrorDetector.ts`
|
|
||||||
|
|
||||||
**Key Methods:**
|
|
||||||
- `classifyError(error, serviceName)` - Analyzes an error and determines if it's network-related
|
|
||||||
- `formatErrorForLogging(result)` - Formats errors for logging with technical details
|
|
||||||
|
|
||||||
### Integration Points
|
|
||||||
|
|
||||||
1. **ImageGenService** (`src/services/ImageGenService.ts`)
|
|
||||||
- Enhanced error handling in `generateImageWithAI()` method
|
|
||||||
- Provides clear network diagnostics when image generation fails
|
|
||||||
|
|
||||||
2. **PromptEnhancementService** (`src/services/promptEnhancement/PromptEnhancementService.ts`)
|
|
||||||
- Enhanced error handling in `enhancePrompt()` method
|
|
||||||
- Helps users diagnose connectivity issues during prompt enhancement
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
### Normal Operation (No Overhead)
|
|
||||||
```
|
|
||||||
User Request → Service → Gemini API → Success ✓
|
|
||||||
```
|
|
||||||
No network checks performed - zero overhead.
|
|
||||||
|
|
||||||
### Error Scenario (Smart Detection)
|
|
||||||
```
|
|
||||||
User Request → Service → Gemini API → Error ✗
|
|
||||||
↓
|
|
||||||
NetworkErrorDetector.classifyError()
|
|
||||||
↓
|
|
||||||
1. Check error code/message for network patterns
|
|
||||||
2. If network-related: Quick DNS check (2s timeout)
|
|
||||||
3. Return classification + user-friendly message
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Types
|
|
||||||
|
|
||||||
| Error Type | Trigger | User Message |
|
|
||||||
|-----------|---------|--------------|
|
|
||||||
| `dns` | ENOTFOUND, EAI_AGAIN | DNS resolution failed: Unable to resolve Gemini API hostname |
|
|
||||||
| `timeout` | ETIMEDOUT | Connection timeout: Gemini API did not respond in time |
|
|
||||||
| `refused` | ECONNREFUSED | Connection refused: Service may be down or blocked by firewall |
|
|
||||||
| `reset` | ECONNRESET, ENETUNREACH | Connection lost: Network connection was interrupted |
|
|
||||||
| `connection` | General connectivity failure | Network connection failed: Unable to reach Gemini API |
|
|
||||||
| `unknown` | Network keywords detected | Network error: Unable to connect to Gemini API |
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Run the manual test to see error detection in action:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx tsx test-network-error-detector.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
This demonstrates:
|
|
||||||
- DNS errors
|
|
||||||
- Timeout errors
|
|
||||||
- Fetch failures (actual error from your logs)
|
|
||||||
- Non-network errors (no false positives)
|
|
||||||
|
|
||||||
## Example Error Logs
|
|
||||||
|
|
||||||
### Before Implementation
|
|
||||||
```
|
|
||||||
[2025-10-09T16:56:29.228Z] [fmfnz0zp7] Text-to-image generation completed: {
|
|
||||||
success: false,
|
|
||||||
error: 'Gemini AI generation failed: exception TypeError: fetch failed sending request'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### After Implementation
|
|
||||||
```
|
|
||||||
[ImageGenService] [NETWORK ERROR - UNKNOWN] Network error: Unable to connect to Gemini API. Please check your internet connection and firewall settings. | Technical: exception TypeError: fetch failed sending request
|
|
||||||
```
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
1. **Better UX**: Users get actionable error messages
|
|
||||||
2. **Faster Debugging**: Developers immediately know if it's a network issue
|
|
||||||
3. **Zero Overhead**: No performance impact on successful requests
|
|
||||||
4. **Production-Ready**: Follows industry best practices (AWS SDK, Stripe, Google Cloud)
|
|
||||||
5. **Comprehensive**: Detects all major network error types
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
Potential improvements:
|
|
||||||
- Retry logic with exponential backoff for transient network errors
|
|
||||||
- Circuit breaker pattern for repeated failures
|
|
||||||
- Metrics/alerting for network error rates
|
|
||||||
- Health check endpoint with connectivity status
|
|
||||||
|
|
@ -13,7 +13,6 @@ module.exports = [
|
||||||
'@typescript-eslint': require('@typescript-eslint/eslint-plugin'),
|
'@typescript-eslint': require('@typescript-eslint/eslint-plugin'),
|
||||||
prettier: require('eslint-plugin-prettier'),
|
prettier: require('eslint-plugin-prettier'),
|
||||||
},
|
},
|
||||||
extends: [require('eslint-config-prettier')],
|
|
||||||
rules: {
|
rules: {
|
||||||
'prettier/prettier': 'error',
|
'prettier/prettier': 'error',
|
||||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
|
|
@ -28,6 +27,12 @@ module.exports = [
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ignores: ['node_modules/**', 'dist/**', '*.js', '*.mjs', 'eslint.config.js'],
|
ignores: [
|
||||||
|
'node_modules/**',
|
||||||
|
'dist/**',
|
||||||
|
'*.js',
|
||||||
|
'*.mjs',
|
||||||
|
'eslint.config.js'
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -71,4 +71,4 @@
|
||||||
"tsx": "^4.20.5",
|
"tsx": "^4.20.5",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,22 +1,24 @@
|
||||||
import express, { Application } from 'express';
|
import express, { Application } from "express";
|
||||||
import cors from 'cors';
|
import cors from "cors";
|
||||||
import { config } from 'dotenv';
|
import { config } from "dotenv";
|
||||||
import { Config } from './types/api';
|
import { Config } from "./types/api";
|
||||||
import { textToImageRouter } from './routes/textToImage';
|
import { generateRouter } from "./routes/generate";
|
||||||
import { imagesRouter } from './routes/images';
|
import { enhanceRouter } from "./routes/enhance";
|
||||||
import bootstrapRoutes from './routes/bootstrap';
|
import { textToImageRouter } from "./routes/textToImage";
|
||||||
import adminKeysRoutes from './routes/admin/keys';
|
import { imagesRouter } from "./routes/images";
|
||||||
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
|
import bootstrapRoutes from "./routes/bootstrap";
|
||||||
|
import adminKeysRoutes from "./routes/admin/keys";
|
||||||
|
import { errorHandler, notFoundHandler } from "./middleware/errorHandler";
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
config();
|
config();
|
||||||
|
|
||||||
// Application configuration
|
// Application configuration
|
||||||
export const appConfig: Config = {
|
export const appConfig: Config = {
|
||||||
port: parseInt(process.env['PORT'] || '3000'),
|
port: parseInt(process.env["PORT"] || "3000"),
|
||||||
geminiApiKey: process.env['GEMINI_API_KEY'] || '',
|
geminiApiKey: process.env["GEMINI_API_KEY"] || "",
|
||||||
resultsDir: './results',
|
resultsDir: "./results",
|
||||||
uploadsDir: './uploads/temp',
|
uploadsDir: "./uploads/temp",
|
||||||
maxFileSize: 5 * 1024 * 1024, // 5MB
|
maxFileSize: 5 * 1024 * 1024, // 5MB
|
||||||
maxFiles: 3,
|
maxFiles: 3,
|
||||||
};
|
};
|
||||||
|
|
@ -30,30 +32,30 @@ export const createApp = (): Application => {
|
||||||
cors({
|
cors({
|
||||||
origin: true, // Allow all origins
|
origin: true, // Allow all origins
|
||||||
credentials: true,
|
credentials: true,
|
||||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
|
allowedHeaders: ["Content-Type", "Authorization", "X-API-Key"],
|
||||||
exposedHeaders: ['X-Request-ID'],
|
exposedHeaders: ["X-Request-ID"],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
app.use(express.json({ limit: '10mb' }));
|
app.use(express.json({ limit: "10mb" }));
|
||||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
||||||
|
|
||||||
// Request ID middleware for logging
|
// Request ID middleware for logging
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
req.requestId = Math.random().toString(36).substr(2, 9);
|
req.requestId = Math.random().toString(36).substr(2, 9);
|
||||||
res.setHeader('X-Request-ID', req.requestId);
|
res.setHeader("X-Request-ID", req.requestId);
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
app.get('/health', (_req, res) => {
|
app.get("/health", (_req, res) => {
|
||||||
const health = {
|
const health = {
|
||||||
status: 'healthy',
|
status: "healthy",
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
uptime: process.uptime(),
|
uptime: process.uptime(),
|
||||||
environment: process.env['NODE_ENV'] || 'development',
|
environment: process.env["NODE_ENV"] || "development",
|
||||||
version: process.env['npm_package_version'] || '1.0.0',
|
version: process.env["npm_package_version"] || "1.0.0",
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`[${health.timestamp}] Health check - ${health.status}`);
|
console.log(`[${health.timestamp}] Health check - ${health.status}`);
|
||||||
|
|
@ -61,29 +63,34 @@ export const createApp = (): Application => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// API info endpoint
|
// API info endpoint
|
||||||
app.get('/api/info', async (req: any, res) => {
|
app.get("/api/info", async (req: any, res) => {
|
||||||
const info: any = {
|
const info: any = {
|
||||||
name: 'Banatie - Nano Banana Image Generation API',
|
name: "Banatie - Nano Banana Image Generation API",
|
||||||
version: '1.0.0',
|
version: "1.0.0",
|
||||||
description:
|
description:
|
||||||
'REST API service for AI-powered image generation using Gemini Flash Image model',
|
"REST API service for AI-powered image generation using Gemini Flash Image model",
|
||||||
endpoints: {
|
endpoints: {
|
||||||
'GET /health': 'Health check',
|
"GET /health": "Health check",
|
||||||
'GET /api/info': 'API information',
|
"GET /api/info": "API information",
|
||||||
'POST /api/text-to-image': 'Generate images from text prompt only (JSON)',
|
"POST /api/generate":
|
||||||
|
"Generate images from text prompt with optional reference images",
|
||||||
|
"POST /api/text-to-image":
|
||||||
|
"Generate images from text prompt only (JSON)",
|
||||||
|
"POST /api/enhance":
|
||||||
|
"Enhance and optimize prompts for better image generation",
|
||||||
},
|
},
|
||||||
limits: {
|
limits: {
|
||||||
maxFileSize: `${appConfig.maxFileSize / (1024 * 1024)}MB`,
|
maxFileSize: `${appConfig.maxFileSize / (1024 * 1024)}MB`,
|
||||||
maxFiles: appConfig.maxFiles,
|
maxFiles: appConfig.maxFiles,
|
||||||
supportedFormats: ['PNG', 'JPEG', 'JPG', 'WebP'],
|
supportedFormats: ["PNG", "JPEG", "JPG", "WebP"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// If API key is provided, validate and return key info
|
// If API key is provided, validate and return key info
|
||||||
const providedKey = req.headers['x-api-key'] as string;
|
const providedKey = req.headers["x-api-key"] as string;
|
||||||
if (providedKey) {
|
if (providedKey) {
|
||||||
try {
|
try {
|
||||||
const { ApiKeyService } = await import('./services/ApiKeyService');
|
const { ApiKeyService } = await import("./services/ApiKeyService");
|
||||||
const apiKeyService = new ApiKeyService();
|
const apiKeyService = new ApiKeyService();
|
||||||
const apiKey = await apiKeyService.validateKey(providedKey);
|
const apiKey = await apiKeyService.validateKey(providedKey);
|
||||||
|
|
||||||
|
|
@ -110,14 +117,16 @@ export const createApp = (): Application => {
|
||||||
|
|
||||||
// Public routes (no authentication)
|
// Public routes (no authentication)
|
||||||
// Bootstrap route (no auth, but works only once)
|
// Bootstrap route (no auth, but works only once)
|
||||||
app.use('/api/bootstrap', bootstrapRoutes);
|
app.use("/api/bootstrap", bootstrapRoutes);
|
||||||
|
|
||||||
// Admin routes (require master key)
|
// Admin routes (require master key)
|
||||||
app.use('/api/admin/keys', adminKeysRoutes);
|
app.use("/api/admin/keys", adminKeysRoutes);
|
||||||
|
|
||||||
// Protected API routes (require valid API key)
|
// Protected API routes (require valid API key)
|
||||||
app.use('/api', textToImageRouter);
|
app.use("/api", generateRouter);
|
||||||
app.use('/api', imagesRouter);
|
app.use("/api", enhanceRouter);
|
||||||
|
app.use("/api", textToImageRouter);
|
||||||
|
app.use("/api", imagesRouter);
|
||||||
|
|
||||||
// Error handling middleware (must be last)
|
// Error handling middleware (must be last)
|
||||||
app.use(notFoundHandler);
|
app.use(notFoundHandler);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
import { createDbClient } from '@banatie/database';
|
import { createDbClient } from '@banatie/database';
|
||||||
|
|
||||||
const DATABASE_URL =
|
const DATABASE_URL = process.env['DATABASE_URL'] ||
|
||||||
process.env['DATABASE_URL'] ||
|
|
||||||
'postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db';
|
'postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db';
|
||||||
|
|
||||||
export const db = createDbClient(DATABASE_URL);
|
export const db = createDbClient(DATABASE_URL);
|
||||||
|
|
||||||
console.log(
|
console.log(`[${new Date().toISOString()}] Database client initialized - ${new URL(DATABASE_URL).host}`);
|
||||||
`[${new Date().toISOString()}] Database client initialized - ${new URL(DATABASE_URL).host}`,
|
|
||||||
);
|
|
||||||
|
|
@ -37,7 +37,7 @@ class RateLimiter {
|
||||||
return {
|
return {
|
||||||
allowed: true,
|
allowed: true,
|
||||||
remaining: this.limit - record.count,
|
remaining: this.limit - record.count,
|
||||||
resetAt: record.resetAt,
|
resetAt: record.resetAt
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,7 +57,11 @@ const rateLimiter = new RateLimiter(100, 60 * 60 * 1000); // 100 requests per ho
|
||||||
* Rate limiting middleware
|
* Rate limiting middleware
|
||||||
* Must be used AFTER validateApiKey middleware
|
* Must be used AFTER validateApiKey middleware
|
||||||
*/
|
*/
|
||||||
export function rateLimitByApiKey(req: Request, res: Response, next: NextFunction): void {
|
export function rateLimitByApiKey(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): void {
|
||||||
if (!req.apiKey) {
|
if (!req.apiKey) {
|
||||||
next();
|
next();
|
||||||
return;
|
return;
|
||||||
|
|
@ -73,12 +77,9 @@ export function rateLimitByApiKey(req: Request, res: Response, next: NextFunctio
|
||||||
if (!result.allowed) {
|
if (!result.allowed) {
|
||||||
const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000);
|
const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000);
|
||||||
|
|
||||||
console.warn(
|
console.warn(`[${new Date().toISOString()}] Rate limit exceeded: ${req.apiKey.id} (${req.apiKey.keyType}) - reset: ${new Date(result.resetAt).toISOString()}`);
|
||||||
`[${new Date().toISOString()}] Rate limit exceeded: ${req.apiKey.id} (${req.apiKey.keyType}) - reset: ${new Date(result.resetAt).toISOString()}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
res
|
res.status(429)
|
||||||
.status(429)
|
|
||||||
.setHeader('Retry-After', retryAfter.toString())
|
.setHeader('Retry-After', retryAfter.toString())
|
||||||
.json({
|
.json({
|
||||||
error: 'Rate limit exceeded',
|
error: 'Rate limit exceeded',
|
||||||
|
|
@ -89,4 +90,4 @@ export function rateLimitByApiKey(req: Request, res: Response, next: NextFunctio
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
@ -4,7 +4,11 @@ import { Request, Response, NextFunction } from 'express';
|
||||||
* Middleware to ensure the API key is a master key
|
* Middleware to ensure the API key is a master key
|
||||||
* Must be used AFTER validateApiKey middleware
|
* Must be used AFTER validateApiKey middleware
|
||||||
*/
|
*/
|
||||||
export function requireMasterKey(req: Request, res: Response, next: NextFunction): void {
|
export function requireMasterKey(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): void {
|
||||||
if (!req.apiKey) {
|
if (!req.apiKey) {
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
error: 'Authentication required',
|
error: 'Authentication required',
|
||||||
|
|
@ -14,9 +18,7 @@ export function requireMasterKey(req: Request, res: Response, next: NextFunction
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.apiKey.keyType !== 'master') {
|
if (req.apiKey.keyType !== 'master') {
|
||||||
console.warn(
|
console.warn(`[${new Date().toISOString()}] Non-master key attempted admin action: ${req.apiKey.id} (${req.apiKey.keyType}) - ${req.path}`);
|
||||||
`[${new Date().toISOString()}] Non-master key attempted admin action: ${req.apiKey.id} (${req.apiKey.keyType}) - ${req.path}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
res.status(403).json({
|
res.status(403).json({
|
||||||
error: 'Master key required',
|
error: 'Master key required',
|
||||||
|
|
@ -26,4 +28,4 @@ export function requireMasterKey(req: Request, res: Response, next: NextFunction
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
@ -4,7 +4,11 @@ import { Request, Response, NextFunction } from 'express';
|
||||||
* Middleware to ensure only project keys can access generation endpoints
|
* Middleware to ensure only project keys can access generation endpoints
|
||||||
* Master keys are for admin purposes only
|
* Master keys are for admin purposes only
|
||||||
*/
|
*/
|
||||||
export function requireProjectKey(req: Request, res: Response, next: NextFunction): void {
|
export function requireProjectKey(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): void {
|
||||||
// This middleware assumes validateApiKey has already run and attached req.apiKey
|
// This middleware assumes validateApiKey has already run and attached req.apiKey
|
||||||
if (!req.apiKey) {
|
if (!req.apiKey) {
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
|
|
@ -18,8 +22,7 @@ export function requireProjectKey(req: Request, res: Response, next: NextFunctio
|
||||||
if (req.apiKey.keyType === 'master') {
|
if (req.apiKey.keyType === 'master') {
|
||||||
res.status(403).json({
|
res.status(403).json({
|
||||||
error: 'Forbidden',
|
error: 'Forbidden',
|
||||||
message:
|
message: 'Master keys cannot be used for image generation. Please use a project-specific API key.',
|
||||||
'Master keys cannot be used for image generation. Please use a project-specific API key.',
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -33,9 +36,7 @@ export function requireProjectKey(req: Request, res: Response, next: NextFunctio
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(`[${new Date().toISOString()}] Project key validated for generation: ${req.apiKey.id}`);
|
||||||
`[${new Date().toISOString()}] Project key validated for generation: ${req.apiKey.id}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ const apiKeyService = new ApiKeyService();
|
||||||
export async function validateApiKey(
|
export async function validateApiKey(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction,
|
next: NextFunction
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const providedKey = req.headers['x-api-key'] as string;
|
const providedKey = req.headers['x-api-key'] as string;
|
||||||
|
|
||||||
|
|
@ -44,9 +44,7 @@ export async function validateApiKey(
|
||||||
// Attach to request for use in routes
|
// Attach to request for use in routes
|
||||||
req.apiKey = apiKey;
|
req.apiKey = apiKey;
|
||||||
|
|
||||||
console.log(
|
console.log(`[${new Date().toISOString()}] API key validated: ${apiKey.id} (${apiKey.keyType})`);
|
||||||
`[${new Date().toISOString()}] API key validated: ${apiKey.id} (${apiKey.keyType})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -56,4 +54,4 @@ export async function validateApiKey(
|
||||||
message: 'An error occurred during authentication',
|
message: 'An error occurred during authentication',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { GenerateImageResponse } from '../types/api';
|
import { GenerateImageResponse } from "../types/api";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global error handler for the Express application
|
* Global error handler for the Express application
|
||||||
*/
|
*/
|
||||||
export const errorHandler = (error: Error, req: Request, res: Response, next: NextFunction) => {
|
export const errorHandler = (
|
||||||
|
error: Error,
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
) => {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const requestId = req.requestId || 'unknown';
|
const requestId = req.requestId || "unknown";
|
||||||
|
|
||||||
// Log the error
|
// Log the error
|
||||||
console.error(`[${timestamp}] [${requestId}] ERROR:`, {
|
console.error(`[${timestamp}] [${requestId}] ERROR:`, {
|
||||||
|
|
@ -25,40 +30,52 @@ export const errorHandler = (error: Error, req: Request, res: Response, next: Ne
|
||||||
|
|
||||||
// Determine error type and status code
|
// Determine error type and status code
|
||||||
let statusCode = 500;
|
let statusCode = 500;
|
||||||
let errorMessage = 'Internal server error';
|
let errorMessage = "Internal server error";
|
||||||
let errorType = 'INTERNAL_ERROR';
|
let errorType = "INTERNAL_ERROR";
|
||||||
|
|
||||||
if (error.name === 'ValidationError') {
|
if (error.name === "ValidationError") {
|
||||||
statusCode = 400;
|
statusCode = 400;
|
||||||
errorMessage = error.message;
|
errorMessage = error.message;
|
||||||
errorType = 'VALIDATION_ERROR';
|
errorType = "VALIDATION_ERROR";
|
||||||
} else if (error.message.includes('API key') || error.message.includes('authentication')) {
|
} else if (
|
||||||
|
error.message.includes("API key") ||
|
||||||
|
error.message.includes("authentication")
|
||||||
|
) {
|
||||||
statusCode = 401;
|
statusCode = 401;
|
||||||
errorMessage = 'Authentication failed';
|
errorMessage = "Authentication failed";
|
||||||
errorType = 'AUTH_ERROR';
|
errorType = "AUTH_ERROR";
|
||||||
} else if (error.message.includes('not found') || error.message.includes('404')) {
|
} else if (
|
||||||
|
error.message.includes("not found") ||
|
||||||
|
error.message.includes("404")
|
||||||
|
) {
|
||||||
statusCode = 404;
|
statusCode = 404;
|
||||||
errorMessage = 'Resource not found';
|
errorMessage = "Resource not found";
|
||||||
errorType = 'NOT_FOUND';
|
errorType = "NOT_FOUND";
|
||||||
} else if (error.message.includes('timeout') || error.message.includes('503')) {
|
} else if (
|
||||||
|
error.message.includes("timeout") ||
|
||||||
|
error.message.includes("503")
|
||||||
|
) {
|
||||||
statusCode = 503;
|
statusCode = 503;
|
||||||
errorMessage = 'Service temporarily unavailable';
|
errorMessage = "Service temporarily unavailable";
|
||||||
errorType = 'SERVICE_UNAVAILABLE';
|
errorType = "SERVICE_UNAVAILABLE";
|
||||||
} else if (error.message.includes('overloaded') || error.message.includes('rate limit')) {
|
} else if (
|
||||||
|
error.message.includes("overloaded") ||
|
||||||
|
error.message.includes("rate limit")
|
||||||
|
) {
|
||||||
statusCode = 429;
|
statusCode = 429;
|
||||||
errorMessage = 'Service overloaded, please try again later';
|
errorMessage = "Service overloaded, please try again later";
|
||||||
errorType = 'RATE_LIMITED';
|
errorType = "RATE_LIMITED";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create error response
|
// Create error response
|
||||||
const errorResponse: GenerateImageResponse = {
|
const errorResponse: GenerateImageResponse = {
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Request failed',
|
message: "Request failed",
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add additional debug info in development
|
// Add additional debug info in development
|
||||||
if (process.env['NODE_ENV'] === 'development') {
|
if (process.env["NODE_ENV"] === "development") {
|
||||||
(errorResponse as any).debug = {
|
(errorResponse as any).debug = {
|
||||||
originalError: error.message,
|
originalError: error.message,
|
||||||
errorType,
|
errorType,
|
||||||
|
|
@ -79,13 +96,15 @@ export const errorHandler = (error: Error, req: Request, res: Response, next: Ne
|
||||||
*/
|
*/
|
||||||
export const notFoundHandler = (req: Request, res: Response) => {
|
export const notFoundHandler = (req: Request, res: Response) => {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const requestId = req.requestId || 'unknown';
|
const requestId = req.requestId || "unknown";
|
||||||
|
|
||||||
console.log(`[${timestamp}] [${requestId}] 404 - Route not found: ${req.method} ${req.path}`);
|
console.log(
|
||||||
|
`[${timestamp}] [${requestId}] 404 - Route not found: ${req.method} ${req.path}`,
|
||||||
|
);
|
||||||
|
|
||||||
const notFoundResponse: GenerateImageResponse = {
|
const notFoundResponse: GenerateImageResponse = {
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Route not found',
|
message: "Route not found",
|
||||||
error: `The requested endpoint ${req.method} ${req.path} does not exist`,
|
error: `The requested endpoint ${req.method} ${req.path} does not exist`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -95,6 +114,7 @@ export const notFoundHandler = (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* Async error wrapper to catch errors in async route handlers
|
* Async error wrapper to catch errors in async route handlers
|
||||||
*/
|
*/
|
||||||
export const asyncHandler = (fn: Function) => (req: Request, res: Response, next: NextFunction) => {
|
export const asyncHandler =
|
||||||
Promise.resolve(fn(req, res, next)).catch(next);
|
(fn: Function) => (req: Request, res: Response, next: NextFunction) => {
|
||||||
};
|
Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Response, NextFunction } from 'express';
|
import { Response, NextFunction } from "express";
|
||||||
import { sanitizeFilename } from './validation';
|
import { sanitizeFilename } from "./validation";
|
||||||
|
|
||||||
// Validation rules (same as existing validation but for JSON)
|
// Validation rules (same as existing validation but for JSON)
|
||||||
const VALIDATION_RULES = {
|
const VALIDATION_RULES = {
|
||||||
|
|
@ -18,16 +18,16 @@ const VALIDATION_RULES = {
|
||||||
|
|
||||||
// Valid aspect ratios supported by Gemini SDK
|
// Valid aspect ratios supported by Gemini SDK
|
||||||
const VALID_ASPECT_RATIOS = [
|
const VALID_ASPECT_RATIOS = [
|
||||||
'1:1', // Square (1024x1024)
|
"1:1", // Square (1024x1024)
|
||||||
'2:3', // Portrait (832x1248)
|
"2:3", // Portrait (832x1248)
|
||||||
'3:2', // Landscape (1248x832)
|
"3:2", // Landscape (1248x832)
|
||||||
'3:4', // Portrait (864x1184)
|
"3:4", // Portrait (864x1184)
|
||||||
'4:3', // Landscape (1184x864)
|
"4:3", // Landscape (1184x864)
|
||||||
'4:5', // Portrait (896x1152)
|
"4:5", // Portrait (896x1152)
|
||||||
'5:4', // Landscape (1152x896)
|
"5:4", // Landscape (1152x896)
|
||||||
'9:16', // Vertical (768x1344)
|
"9:16", // Vertical (768x1344)
|
||||||
'16:9', // Widescreen (1344x768)
|
"16:9", // Widescreen (1344x768)
|
||||||
'21:9', // Ultrawide (1536x672)
|
"21:9", // Ultrawide (1536x672)
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -42,14 +42,16 @@ export const validateTextToImageRequest = (
|
||||||
const { prompt, filename, aspectRatio, autoEnhance, enhancementOptions } = req.body;
|
const { prompt, filename, aspectRatio, autoEnhance, enhancementOptions } = req.body;
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
console.log(`[${timestamp}] [${req.requestId}] Validating text-to-image JSON request`);
|
console.log(
|
||||||
|
`[${timestamp}] [${req.requestId}] Validating text-to-image JSON request`,
|
||||||
|
);
|
||||||
|
|
||||||
// Validate that request body exists
|
// Validate that request body exists
|
||||||
if (!req.body || typeof req.body !== 'object') {
|
if (!req.body || typeof req.body !== "object") {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Request body must be valid JSON',
|
error: "Request body must be valid JSON",
|
||||||
message: 'Invalid request format',
|
message: "Invalid request format",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,54 +63,67 @@ export const validateTextToImageRequest = (
|
||||||
|
|
||||||
// Default template to "photorealistic" in enhancementOptions
|
// Default template to "photorealistic" in enhancementOptions
|
||||||
if (req.body.enhancementOptions && !req.body.enhancementOptions.template) {
|
if (req.body.enhancementOptions && !req.body.enhancementOptions.template) {
|
||||||
req.body.enhancementOptions.template = 'photorealistic';
|
req.body.enhancementOptions.template = "photorealistic";
|
||||||
} else if (!req.body.enhancementOptions && req.body.autoEnhance !== false) {
|
} else if (!req.body.enhancementOptions && req.body.autoEnhance !== false) {
|
||||||
// If autoEnhance is true (default) and no enhancementOptions, create it with default template
|
// If autoEnhance is true (default) and no enhancementOptions, create it with default template
|
||||||
req.body.enhancementOptions = { template: 'photorealistic' };
|
req.body.enhancementOptions = { template: "photorealistic" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate prompt
|
// Validate prompt
|
||||||
if (!prompt) {
|
if (!prompt) {
|
||||||
errors.push('Prompt is required');
|
errors.push("Prompt is required");
|
||||||
} else if (typeof prompt !== 'string') {
|
} else if (typeof prompt !== "string") {
|
||||||
errors.push('Prompt must be a string');
|
errors.push("Prompt must be a string");
|
||||||
} else if (prompt.trim().length < VALIDATION_RULES.prompt.minLength) {
|
} else if (prompt.trim().length < VALIDATION_RULES.prompt.minLength) {
|
||||||
errors.push(`Prompt must be at least ${VALIDATION_RULES.prompt.minLength} characters`);
|
errors.push(
|
||||||
|
`Prompt must be at least ${VALIDATION_RULES.prompt.minLength} characters`,
|
||||||
|
);
|
||||||
} else if (prompt.length > VALIDATION_RULES.prompt.maxLength) {
|
} else if (prompt.length > VALIDATION_RULES.prompt.maxLength) {
|
||||||
errors.push(`Prompt must be less than ${VALIDATION_RULES.prompt.maxLength} characters`);
|
errors.push(
|
||||||
|
`Prompt must be less than ${VALIDATION_RULES.prompt.maxLength} characters`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate filename
|
// Validate filename
|
||||||
if (!filename) {
|
if (!filename) {
|
||||||
errors.push('Filename is required');
|
errors.push("Filename is required");
|
||||||
} else if (typeof filename !== 'string') {
|
} else if (typeof filename !== "string") {
|
||||||
errors.push('Filename must be a string');
|
errors.push("Filename must be a string");
|
||||||
} else if (filename.trim().length < VALIDATION_RULES.filename.minLength) {
|
} else if (filename.trim().length < VALIDATION_RULES.filename.minLength) {
|
||||||
errors.push('Filename cannot be empty');
|
errors.push("Filename cannot be empty");
|
||||||
} else if (filename.length > VALIDATION_RULES.filename.maxLength) {
|
} else if (filename.length > VALIDATION_RULES.filename.maxLength) {
|
||||||
errors.push(`Filename must be less than ${VALIDATION_RULES.filename.maxLength} characters`);
|
errors.push(
|
||||||
|
`Filename must be less than ${VALIDATION_RULES.filename.maxLength} characters`,
|
||||||
|
);
|
||||||
} else if (!VALIDATION_RULES.filename.pattern.test(filename)) {
|
} else if (!VALIDATION_RULES.filename.pattern.test(filename)) {
|
||||||
errors.push('Filename can only contain letters, numbers, underscores, and hyphens');
|
errors.push(
|
||||||
|
"Filename can only contain letters, numbers, underscores, and hyphens",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate aspectRatio (optional, defaults to "1:1")
|
// Validate aspectRatio (optional, defaults to "1:1")
|
||||||
if (aspectRatio !== undefined) {
|
if (aspectRatio !== undefined) {
|
||||||
if (typeof aspectRatio !== 'string') {
|
if (typeof aspectRatio !== "string") {
|
||||||
errors.push('aspectRatio must be a string');
|
errors.push("aspectRatio must be a string");
|
||||||
} else if (!VALID_ASPECT_RATIOS.includes(aspectRatio as any)) {
|
} else if (!VALID_ASPECT_RATIOS.includes(aspectRatio as any)) {
|
||||||
errors.push(`Invalid aspectRatio. Must be one of: ${VALID_ASPECT_RATIOS.join(', ')}`);
|
errors.push(
|
||||||
|
`Invalid aspectRatio. Must be one of: ${VALID_ASPECT_RATIOS.join(", ")}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate autoEnhance (optional boolean)
|
// Validate autoEnhance (optional boolean)
|
||||||
if (autoEnhance !== undefined && typeof autoEnhance !== 'boolean') {
|
if (autoEnhance !== undefined && typeof autoEnhance !== "boolean") {
|
||||||
errors.push('autoEnhance must be a boolean');
|
errors.push("autoEnhance must be a boolean");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate enhancementOptions (optional object)
|
// Validate enhancementOptions (optional object)
|
||||||
if (enhancementOptions !== undefined) {
|
if (enhancementOptions !== undefined) {
|
||||||
if (typeof enhancementOptions !== 'object' || Array.isArray(enhancementOptions)) {
|
if (
|
||||||
errors.push('enhancementOptions must be an object');
|
typeof enhancementOptions !== "object" ||
|
||||||
|
Array.isArray(enhancementOptions)
|
||||||
|
) {
|
||||||
|
errors.push("enhancementOptions must be an object");
|
||||||
} else {
|
} else {
|
||||||
const { template } = enhancementOptions;
|
const { template } = enhancementOptions;
|
||||||
|
|
||||||
|
|
@ -116,17 +131,17 @@ export const validateTextToImageRequest = (
|
||||||
if (
|
if (
|
||||||
template !== undefined &&
|
template !== undefined &&
|
||||||
![
|
![
|
||||||
'photorealistic',
|
"photorealistic",
|
||||||
'illustration',
|
"illustration",
|
||||||
'minimalist',
|
"minimalist",
|
||||||
'sticker',
|
"sticker",
|
||||||
'product',
|
"product",
|
||||||
'comic',
|
"comic",
|
||||||
'general',
|
"general",
|
||||||
].includes(template)
|
].includes(template)
|
||||||
) {
|
) {
|
||||||
errors.push(
|
errors.push(
|
||||||
'Invalid template in enhancementOptions. Must be one of: photorealistic, illustration, minimalist, sticker, product, comic, general',
|
"Invalid template in enhancementOptions. Must be one of: photorealistic, illustration, minimalist, sticker, product, comic, general",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -134,16 +149,19 @@ export const validateTextToImageRequest = (
|
||||||
|
|
||||||
// Validate meta (optional object)
|
// Validate meta (optional object)
|
||||||
if (req.body.meta !== undefined) {
|
if (req.body.meta !== undefined) {
|
||||||
if (typeof req.body.meta !== 'object' || Array.isArray(req.body.meta)) {
|
if (
|
||||||
errors.push('meta must be an object');
|
typeof req.body.meta !== "object" ||
|
||||||
|
Array.isArray(req.body.meta)
|
||||||
|
) {
|
||||||
|
errors.push("meta must be an object");
|
||||||
} else if (req.body.meta.tags !== undefined) {
|
} else if (req.body.meta.tags !== undefined) {
|
||||||
if (!Array.isArray(req.body.meta.tags)) {
|
if (!Array.isArray(req.body.meta.tags)) {
|
||||||
errors.push('meta.tags must be an array');
|
errors.push("meta.tags must be an array");
|
||||||
} else {
|
} else {
|
||||||
// Validate each tag is a string
|
// Validate each tag is a string
|
||||||
for (const tag of req.body.meta.tags) {
|
for (const tag of req.body.meta.tags) {
|
||||||
if (typeof tag !== 'string') {
|
if (typeof tag !== "string") {
|
||||||
errors.push('Each tag in meta.tags must be a string');
|
errors.push("Each tag in meta.tags must be a string");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -152,19 +170,28 @@ export const validateTextToImageRequest = (
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for XSS attempts in prompt
|
// Check for XSS attempts in prompt
|
||||||
const xssPatterns = [/<script/i, /javascript:/i, /on\w+\s*=/i, /<iframe/i, /<object/i, /<embed/i];
|
const xssPatterns = [
|
||||||
|
/<script/i,
|
||||||
|
/javascript:/i,
|
||||||
|
/on\w+\s*=/i,
|
||||||
|
/<iframe/i,
|
||||||
|
/<object/i,
|
||||||
|
/<embed/i,
|
||||||
|
];
|
||||||
|
|
||||||
if (prompt && xssPatterns.some((pattern) => pattern.test(prompt))) {
|
if (prompt && xssPatterns.some((pattern) => pattern.test(prompt))) {
|
||||||
errors.push('Invalid characters detected in prompt');
|
errors.push("Invalid characters detected in prompt");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log validation results
|
// Log validation results
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
console.log(`[${timestamp}] [${req.requestId}] Validation failed: ${errors.join(', ')}`);
|
console.log(
|
||||||
|
`[${timestamp}] [${req.requestId}] Validation failed: ${errors.join(", ")}`,
|
||||||
|
);
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Validation failed',
|
error: "Validation failed",
|
||||||
message: errors.join(', '),
|
message: errors.join(", "),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,23 +217,40 @@ export const validateTextToImageRequest = (
|
||||||
/**
|
/**
|
||||||
* Log text-to-image request details for debugging
|
* Log text-to-image request details for debugging
|
||||||
*/
|
*/
|
||||||
export const logTextToImageRequest = (req: any, _res: Response, next: NextFunction): void => {
|
export const logTextToImageRequest = (
|
||||||
|
req: any,
|
||||||
|
_res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
): void => {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const { prompt, filename, autoEnhance, enhancementOptions } = req.body;
|
const { prompt, filename, autoEnhance, enhancementOptions } = req.body;
|
||||||
|
|
||||||
console.log(`[${timestamp}] [${req.requestId}] === TEXT-TO-IMAGE REQUEST ===`);
|
console.log(
|
||||||
|
`[${timestamp}] [${req.requestId}] === TEXT-TO-IMAGE REQUEST ===`,
|
||||||
|
);
|
||||||
console.log(`[${timestamp}] [${req.requestId}] Method: ${req.method}`);
|
console.log(`[${timestamp}] [${req.requestId}] Method: ${req.method}`);
|
||||||
console.log(`[${timestamp}] [${req.requestId}] Path: ${req.path}`);
|
console.log(`[${timestamp}] [${req.requestId}] Path: ${req.path}`);
|
||||||
console.log(`[${timestamp}] [${req.requestId}] Content-Type: ${req.get('Content-Type')}`);
|
|
||||||
console.log(
|
console.log(
|
||||||
`[${timestamp}] [${req.requestId}] Prompt: "${prompt?.substring(0, 100)}${prompt?.length > 100 ? '...' : ''}"`,
|
`[${timestamp}] [${req.requestId}] Content-Type: ${req.get("Content-Type")}`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`[${timestamp}] [${req.requestId}] Prompt: "${prompt?.substring(0, 100)}${prompt?.length > 100 ? "..." : ""}"`,
|
||||||
);
|
);
|
||||||
console.log(`[${timestamp}] [${req.requestId}] Filename: "${filename}"`);
|
console.log(`[${timestamp}] [${req.requestId}] Filename: "${filename}"`);
|
||||||
console.log(`[${timestamp}] [${req.requestId}] Auto-enhance: ${autoEnhance || false}`);
|
console.log(
|
||||||
|
`[${timestamp}] [${req.requestId}] Auto-enhance: ${autoEnhance || false}`,
|
||||||
|
);
|
||||||
if (enhancementOptions) {
|
if (enhancementOptions) {
|
||||||
console.log(`[${timestamp}] [${req.requestId}] Enhancement options:`, enhancementOptions);
|
console.log(
|
||||||
|
`[${timestamp}] [${req.requestId}] Enhancement options:`,
|
||||||
|
enhancementOptions,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
console.log(`[${timestamp}] [${req.requestId}] Reference files: 0 (text-only endpoint)`);
|
console.log(
|
||||||
console.log(`[${timestamp}] [${req.requestId}] =====================================`);
|
`[${timestamp}] [${req.requestId}] Reference files: 0 (text-only endpoint)`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`[${timestamp}] [${req.requestId}] =====================================`,
|
||||||
|
);
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from "express";
|
||||||
import { PromptEnhancementService } from '../services/promptEnhancement';
|
import { PromptEnhancementService } from "../services/promptEnhancement";
|
||||||
import { EnhancedGenerateImageRequest } from '../types/api';
|
import { EnhancedGenerateImageRequest } from "../types/api";
|
||||||
|
|
||||||
let promptEnhancementService: PromptEnhancementService | null = null;
|
let promptEnhancementService: PromptEnhancementService | null = null;
|
||||||
|
|
||||||
|
|
@ -28,15 +28,19 @@ export const autoEnhancePrompt = async (
|
||||||
const shouldEnhance = autoEnhance !== false;
|
const shouldEnhance = autoEnhance !== false;
|
||||||
|
|
||||||
if (!shouldEnhance) {
|
if (!shouldEnhance) {
|
||||||
console.log(`[${timestamp}] [${requestId}] Auto-enhancement explicitly disabled, skipping`);
|
console.log(
|
||||||
|
`[${timestamp}] [${requestId}] Auto-enhancement explicitly disabled, skipping`,
|
||||||
|
);
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[${timestamp}] [${requestId}] Auto-enhancement enabled, processing prompt`);
|
console.log(
|
||||||
|
`[${timestamp}] [${requestId}] Auto-enhancement enabled, processing prompt`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!promptEnhancementService) {
|
if (!promptEnhancementService) {
|
||||||
const apiKey = process.env['GEMINI_API_KEY'];
|
const apiKey = process.env["GEMINI_API_KEY"];
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
console.error(
|
console.error(
|
||||||
`[${timestamp}] [${requestId}] Cannot initialize prompt enhancement: Missing API key`,
|
`[${timestamp}] [${requestId}] Cannot initialize prompt enhancement: Missing API key`,
|
||||||
|
|
@ -47,8 +51,8 @@ export const autoEnhancePrompt = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract orgId and projectId from validated API key
|
// Extract orgId and projectId from validated API key
|
||||||
const orgId = req.apiKey?.organizationSlug || 'unknown';
|
const orgId = req.apiKey?.organizationSlug || "unknown";
|
||||||
const projectId = req.apiKey?.projectSlug || 'unknown';
|
const projectId = req.apiKey?.projectSlug || "unknown";
|
||||||
|
|
||||||
const result = await promptEnhancementService.enhancePrompt(
|
const result = await promptEnhancementService.enhancePrompt(
|
||||||
prompt,
|
prompt,
|
||||||
|
|
@ -65,7 +69,9 @@ export const autoEnhancePrompt = async (
|
||||||
|
|
||||||
if (result.success && result.enhancedPrompt) {
|
if (result.success && result.enhancedPrompt) {
|
||||||
console.log(`[${timestamp}] [${requestId}] Prompt enhanced successfully`);
|
console.log(`[${timestamp}] [${requestId}] Prompt enhanced successfully`);
|
||||||
console.log(`[${timestamp}] [${requestId}] Original: "${prompt.substring(0, 50)}..."`);
|
console.log(
|
||||||
|
`[${timestamp}] [${requestId}] Original: "${prompt.substring(0, 50)}..."`,
|
||||||
|
);
|
||||||
console.log(
|
console.log(
|
||||||
`[${timestamp}] [${requestId}] Enhanced: "${result.enhancedPrompt.substring(0, 50)}..."`,
|
`[${timestamp}] [${requestId}] Enhanced: "${result.enhancedPrompt.substring(0, 50)}..."`,
|
||||||
);
|
);
|
||||||
|
|
@ -84,12 +90,21 @@ export const autoEnhancePrompt = async (
|
||||||
|
|
||||||
req.body.prompt = result.enhancedPrompt;
|
req.body.prompt = result.enhancedPrompt;
|
||||||
} else {
|
} else {
|
||||||
console.warn(`[${timestamp}] [${requestId}] Prompt enhancement failed: ${result.error}`);
|
console.warn(
|
||||||
console.log(`[${timestamp}] [${requestId}] Proceeding with original prompt`);
|
`[${timestamp}] [${requestId}] Prompt enhancement failed: ${result.error}`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`[${timestamp}] [${requestId}] Proceeding with original prompt`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[${timestamp}] [${requestId}] Error during auto-enhancement:`, error);
|
console.error(
|
||||||
console.log(`[${timestamp}] [${requestId}] Proceeding with original prompt`);
|
`[${timestamp}] [${requestId}] Error during auto-enhancement:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`[${timestamp}] [${requestId}] Proceeding with original prompt`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
import multer from 'multer';
|
import multer from "multer";
|
||||||
import { Request, RequestHandler } from 'express';
|
import { Request, RequestHandler } from "express";
|
||||||
|
|
||||||
// Configure multer for memory storage (we'll process files in memory)
|
// Configure multer for memory storage (we'll process files in memory)
|
||||||
const storage = multer.memoryStorage();
|
const storage = multer.memoryStorage();
|
||||||
|
|
||||||
// File filter for image types only
|
// File filter for image types only
|
||||||
const fileFilter = (_req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
|
const fileFilter = (
|
||||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
|
_req: Request,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
cb: multer.FileFilterCallback,
|
||||||
|
) => {
|
||||||
|
const allowedTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp"];
|
||||||
|
|
||||||
if (allowedTypes.includes(file.mimetype)) {
|
if (allowedTypes.includes(file.mimetype)) {
|
||||||
console.log(
|
console.log(
|
||||||
|
|
@ -17,7 +21,11 @@ const fileFilter = (_req: Request, file: Express.Multer.File, cb: multer.FileFil
|
||||||
console.log(
|
console.log(
|
||||||
`[${new Date().toISOString()}] Rejected file: ${file.originalname} (${file.mimetype})`,
|
`[${new Date().toISOString()}] Rejected file: ${file.originalname} (${file.mimetype})`,
|
||||||
);
|
);
|
||||||
cb(new Error(`Unsupported file type: ${file.mimetype}. Allowed: PNG, JPEG, WebP`));
|
cb(
|
||||||
|
new Error(
|
||||||
|
`Unsupported file type: ${file.mimetype}. Allowed: PNG, JPEG, WebP`,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -36,50 +44,59 @@ export const upload = multer({
|
||||||
});
|
});
|
||||||
|
|
||||||
// Middleware for handling reference images
|
// Middleware for handling reference images
|
||||||
export const uploadReferenceImages: RequestHandler = upload.array('referenceImages', MAX_FILES);
|
export const uploadReferenceImages: RequestHandler = upload.array(
|
||||||
|
"referenceImages",
|
||||||
|
MAX_FILES,
|
||||||
|
);
|
||||||
|
|
||||||
// Error handler for multer errors
|
// Error handler for multer errors
|
||||||
export const handleUploadErrors = (error: any, _req: Request, res: any, next: any) => {
|
export const handleUploadErrors = (
|
||||||
|
error: any,
|
||||||
|
_req: Request,
|
||||||
|
res: any,
|
||||||
|
next: any,
|
||||||
|
) => {
|
||||||
if (error instanceof multer.MulterError) {
|
if (error instanceof multer.MulterError) {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
console.error(`[${timestamp}] Multer error:`, error.message);
|
console.error(`[${timestamp}] Multer error:`, error.message);
|
||||||
|
|
||||||
switch (error.code) {
|
switch (error.code) {
|
||||||
case 'LIMIT_FILE_SIZE':
|
case "LIMIT_FILE_SIZE":
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: `File too large. Maximum size: ${MAX_FILE_SIZE / (1024 * 1024)}MB`,
|
error: `File too large. Maximum size: ${MAX_FILE_SIZE / (1024 * 1024)}MB`,
|
||||||
message: 'File upload failed',
|
message: "File upload failed",
|
||||||
});
|
});
|
||||||
|
|
||||||
case 'LIMIT_FILE_COUNT':
|
case "LIMIT_FILE_COUNT":
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: `Too many files. Maximum: ${MAX_FILES} files`,
|
error: `Too many files. Maximum: ${MAX_FILES} files`,
|
||||||
message: 'File upload failed',
|
message: "File upload failed",
|
||||||
});
|
});
|
||||||
|
|
||||||
case 'LIMIT_UNEXPECTED_FILE':
|
case "LIMIT_UNEXPECTED_FILE":
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Unexpected file field. Use "referenceImages" for image uploads',
|
error:
|
||||||
message: 'File upload failed',
|
'Unexpected file field. Use "referenceImages" for image uploads',
|
||||||
|
message: "File upload failed",
|
||||||
});
|
});
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
message: 'File upload failed',
|
message: "File upload failed",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error.message.includes('Unsupported file type')) {
|
if (error.message.includes("Unsupported file type")) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
message: 'File validation failed',
|
message: "File validation failed",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Response, NextFunction } from 'express';
|
import { Response, NextFunction } from "express";
|
||||||
|
|
||||||
// Validation rules
|
// Validation rules
|
||||||
const VALIDATION_RULES = {
|
const VALIDATION_RULES = {
|
||||||
|
|
@ -20,9 +20,9 @@ const VALIDATION_RULES = {
|
||||||
*/
|
*/
|
||||||
export const sanitizeFilename = (filename: string): string => {
|
export const sanitizeFilename = (filename: string): string => {
|
||||||
return filename
|
return filename
|
||||||
.replace(/[^a-zA-Z0-9_-]/g, '_') // Replace invalid chars with underscore
|
.replace(/[^a-zA-Z0-9_-]/g, "_") // Replace invalid chars with underscore
|
||||||
.replace(/_{2,}/g, '_') // Replace multiple underscores with single
|
.replace(/_{2,}/g, "_") // Replace multiple underscores with single
|
||||||
.replace(/^_+|_+$/g, '') // Remove leading/trailing underscores
|
.replace(/^_+|_+$/g, "") // Remove leading/trailing underscores
|
||||||
.substring(0, 100); // Limit length
|
.substring(0, 100); // Limit length
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -39,17 +39,17 @@ export const validateGenerateRequest = (
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
// Convert string values from multipart form data
|
// Convert string values from multipart form data
|
||||||
if (typeof autoEnhance === 'string') {
|
if (typeof autoEnhance === "string") {
|
||||||
autoEnhance = autoEnhance.toLowerCase() === 'true';
|
autoEnhance = autoEnhance.toLowerCase() === "true";
|
||||||
req.body.autoEnhance = autoEnhance;
|
req.body.autoEnhance = autoEnhance;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof enhancementOptions === 'string') {
|
if (typeof enhancementOptions === "string") {
|
||||||
try {
|
try {
|
||||||
enhancementOptions = JSON.parse(enhancementOptions);
|
enhancementOptions = JSON.parse(enhancementOptions);
|
||||||
req.body.enhancementOptions = enhancementOptions;
|
req.body.enhancementOptions = enhancementOptions;
|
||||||
} catch {
|
} catch {
|
||||||
errors.push('enhancementOptions must be valid JSON');
|
errors.push("enhancementOptions must be valid JSON");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,79 +57,111 @@ export const validateGenerateRequest = (
|
||||||
|
|
||||||
// Validate prompt
|
// Validate prompt
|
||||||
if (!prompt) {
|
if (!prompt) {
|
||||||
errors.push('Prompt is required');
|
errors.push("Prompt is required");
|
||||||
} else if (typeof prompt !== 'string') {
|
} else if (typeof prompt !== "string") {
|
||||||
errors.push('Prompt must be a string');
|
errors.push("Prompt must be a string");
|
||||||
} else if (prompt.trim().length < VALIDATION_RULES.prompt.minLength) {
|
} else if (prompt.trim().length < VALIDATION_RULES.prompt.minLength) {
|
||||||
errors.push(`Prompt must be at least ${VALIDATION_RULES.prompt.minLength} characters`);
|
errors.push(
|
||||||
|
`Prompt must be at least ${VALIDATION_RULES.prompt.minLength} characters`,
|
||||||
|
);
|
||||||
} else if (prompt.length > VALIDATION_RULES.prompt.maxLength) {
|
} else if (prompt.length > VALIDATION_RULES.prompt.maxLength) {
|
||||||
errors.push(`Prompt must be less than ${VALIDATION_RULES.prompt.maxLength} characters`);
|
errors.push(
|
||||||
|
`Prompt must be less than ${VALIDATION_RULES.prompt.maxLength} characters`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate filename
|
// Validate filename
|
||||||
if (!filename) {
|
if (!filename) {
|
||||||
errors.push('Filename is required');
|
errors.push("Filename is required");
|
||||||
} else if (typeof filename !== 'string') {
|
} else if (typeof filename !== "string") {
|
||||||
errors.push('Filename must be a string');
|
errors.push("Filename must be a string");
|
||||||
} else if (filename.trim().length < VALIDATION_RULES.filename.minLength) {
|
} else if (filename.trim().length < VALIDATION_RULES.filename.minLength) {
|
||||||
errors.push('Filename cannot be empty');
|
errors.push("Filename cannot be empty");
|
||||||
} else if (filename.length > VALIDATION_RULES.filename.maxLength) {
|
} else if (filename.length > VALIDATION_RULES.filename.maxLength) {
|
||||||
errors.push(`Filename must be less than ${VALIDATION_RULES.filename.maxLength} characters`);
|
errors.push(
|
||||||
|
`Filename must be less than ${VALIDATION_RULES.filename.maxLength} characters`,
|
||||||
|
);
|
||||||
} else if (!VALIDATION_RULES.filename.pattern.test(filename)) {
|
} else if (!VALIDATION_RULES.filename.pattern.test(filename)) {
|
||||||
errors.push('Filename can only contain letters, numbers, underscores, and hyphens');
|
errors.push(
|
||||||
|
"Filename can only contain letters, numbers, underscores, and hyphens",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate autoEnhance (optional boolean)
|
// Validate autoEnhance (optional boolean)
|
||||||
if (autoEnhance !== undefined && typeof autoEnhance !== 'boolean') {
|
if (autoEnhance !== undefined && typeof autoEnhance !== "boolean") {
|
||||||
errors.push('autoEnhance must be a boolean');
|
errors.push("autoEnhance must be a boolean");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate enhancementOptions (optional object)
|
// Validate enhancementOptions (optional object)
|
||||||
if (enhancementOptions !== undefined) {
|
if (enhancementOptions !== undefined) {
|
||||||
if (typeof enhancementOptions !== 'object' || Array.isArray(enhancementOptions)) {
|
if (
|
||||||
errors.push('enhancementOptions must be an object');
|
typeof enhancementOptions !== "object" ||
|
||||||
|
Array.isArray(enhancementOptions)
|
||||||
|
) {
|
||||||
|
errors.push("enhancementOptions must be an object");
|
||||||
} else {
|
} else {
|
||||||
const { imageStyle, aspectRatio, mood, lighting, cameraAngle, negativePrompts } =
|
const {
|
||||||
enhancementOptions;
|
imageStyle,
|
||||||
|
aspectRatio,
|
||||||
|
mood,
|
||||||
|
lighting,
|
||||||
|
cameraAngle,
|
||||||
|
negativePrompts,
|
||||||
|
} = enhancementOptions;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
imageStyle !== undefined &&
|
imageStyle !== undefined &&
|
||||||
!['photorealistic', 'illustration', 'minimalist', 'sticker', 'product', 'comic'].includes(
|
![
|
||||||
imageStyle,
|
"photorealistic",
|
||||||
)
|
"illustration",
|
||||||
|
"minimalist",
|
||||||
|
"sticker",
|
||||||
|
"product",
|
||||||
|
"comic",
|
||||||
|
].includes(imageStyle)
|
||||||
) {
|
) {
|
||||||
errors.push('Invalid imageStyle in enhancementOptions');
|
errors.push("Invalid imageStyle in enhancementOptions");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
aspectRatio !== undefined &&
|
aspectRatio !== undefined &&
|
||||||
!['square', 'portrait', 'landscape', 'wide', 'ultrawide'].includes(aspectRatio)
|
!["square", "portrait", "landscape", "wide", "ultrawide"].includes(
|
||||||
|
aspectRatio,
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
errors.push('Invalid aspectRatio in enhancementOptions');
|
errors.push("Invalid aspectRatio in enhancementOptions");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mood !== undefined && (typeof mood !== 'string' || mood.length > 100)) {
|
if (
|
||||||
errors.push('mood must be a string with max 100 characters');
|
mood !== undefined &&
|
||||||
|
(typeof mood !== "string" || mood.length > 100)
|
||||||
|
) {
|
||||||
|
errors.push("mood must be a string with max 100 characters");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lighting !== undefined && (typeof lighting !== 'string' || lighting.length > 100)) {
|
if (
|
||||||
errors.push('lighting must be a string with max 100 characters');
|
lighting !== undefined &&
|
||||||
|
(typeof lighting !== "string" || lighting.length > 100)
|
||||||
|
) {
|
||||||
|
errors.push("lighting must be a string with max 100 characters");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
cameraAngle !== undefined &&
|
cameraAngle !== undefined &&
|
||||||
(typeof cameraAngle !== 'string' || cameraAngle.length > 100)
|
(typeof cameraAngle !== "string" || cameraAngle.length > 100)
|
||||||
) {
|
) {
|
||||||
errors.push('cameraAngle must be a string with max 100 characters');
|
errors.push("cameraAngle must be a string with max 100 characters");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (negativePrompts !== undefined) {
|
if (negativePrompts !== undefined) {
|
||||||
if (!Array.isArray(negativePrompts) || negativePrompts.length > 10) {
|
if (!Array.isArray(negativePrompts) || negativePrompts.length > 10) {
|
||||||
errors.push('negativePrompts must be an array with max 10 items');
|
errors.push("negativePrompts must be an array with max 10 items");
|
||||||
} else {
|
} else {
|
||||||
for (const item of negativePrompts) {
|
for (const item of negativePrompts) {
|
||||||
if (typeof item !== 'string' || item.length > 100) {
|
if (typeof item !== "string" || item.length > 100) {
|
||||||
errors.push('Each negative prompt must be a string with max 100 characters');
|
errors.push(
|
||||||
|
"Each negative prompt must be a string with max 100 characters",
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -139,19 +171,28 @@ export const validateGenerateRequest = (
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for XSS attempts in prompt
|
// Check for XSS attempts in prompt
|
||||||
const xssPatterns = [/<script/i, /javascript:/i, /on\w+\s*=/i, /<iframe/i, /<object/i, /<embed/i];
|
const xssPatterns = [
|
||||||
|
/<script/i,
|
||||||
|
/javascript:/i,
|
||||||
|
/on\w+\s*=/i,
|
||||||
|
/<iframe/i,
|
||||||
|
/<object/i,
|
||||||
|
/<embed/i,
|
||||||
|
];
|
||||||
|
|
||||||
if (prompt && xssPatterns.some((pattern) => pattern.test(prompt))) {
|
if (prompt && xssPatterns.some((pattern) => pattern.test(prompt))) {
|
||||||
errors.push('Invalid characters detected in prompt');
|
errors.push("Invalid characters detected in prompt");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log validation results
|
// Log validation results
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
console.log(`[${timestamp}] [${req.requestId}] Validation failed: ${errors.join(', ')}`);
|
console.log(
|
||||||
|
`[${timestamp}] [${req.requestId}] Validation failed: ${errors.join(", ")}`,
|
||||||
|
);
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Validation failed',
|
error: "Validation failed",
|
||||||
message: errors.join(', '),
|
message: errors.join(", "),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,7 +218,11 @@ export const validateGenerateRequest = (
|
||||||
/**
|
/**
|
||||||
* Log request details for debugging
|
* Log request details for debugging
|
||||||
*/
|
*/
|
||||||
export const logRequestDetails = (req: any, _res: Response, next: NextFunction): void => {
|
export const logRequestDetails = (
|
||||||
|
req: any,
|
||||||
|
_res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
): void => {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const { prompt, filename, autoEnhance, enhancementOptions } = req.body;
|
const { prompt, filename, autoEnhance, enhancementOptions } = req.body;
|
||||||
const files = (req.files as Express.Multer.File[]) || [];
|
const files = (req.files as Express.Multer.File[]) || [];
|
||||||
|
|
@ -186,14 +231,21 @@ export const logRequestDetails = (req: any, _res: Response, next: NextFunction):
|
||||||
console.log(`[${timestamp}] [${req.requestId}] Method: ${req.method}`);
|
console.log(`[${timestamp}] [${req.requestId}] Method: ${req.method}`);
|
||||||
console.log(`[${timestamp}] [${req.requestId}] Path: ${req.path}`);
|
console.log(`[${timestamp}] [${req.requestId}] Path: ${req.path}`);
|
||||||
console.log(
|
console.log(
|
||||||
`[${timestamp}] [${req.requestId}] Prompt: "${prompt?.substring(0, 100)}${prompt?.length > 100 ? '...' : ''}"`,
|
`[${timestamp}] [${req.requestId}] Prompt: "${prompt?.substring(0, 100)}${prompt?.length > 100 ? "..." : ""}"`,
|
||||||
);
|
);
|
||||||
console.log(`[${timestamp}] [${req.requestId}] Filename: "${filename}"`);
|
console.log(`[${timestamp}] [${req.requestId}] Filename: "${filename}"`);
|
||||||
console.log(`[${timestamp}] [${req.requestId}] Auto-enhance: ${autoEnhance || false}`);
|
console.log(
|
||||||
|
`[${timestamp}] [${req.requestId}] Auto-enhance: ${autoEnhance || false}`,
|
||||||
|
);
|
||||||
if (enhancementOptions) {
|
if (enhancementOptions) {
|
||||||
console.log(`[${timestamp}] [${req.requestId}] Enhancement options:`, enhancementOptions);
|
console.log(
|
||||||
|
`[${timestamp}] [${req.requestId}] Enhancement options:`,
|
||||||
|
enhancementOptions,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
console.log(`[${timestamp}] [${req.requestId}] Reference files: ${files.length}`);
|
console.log(
|
||||||
|
`[${timestamp}] [${req.requestId}] Reference files: ${files.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
files.forEach((file, index) => {
|
files.forEach((file, index) => {
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ router.post('/', async (req, res) => {
|
||||||
organizationName,
|
organizationName,
|
||||||
projectName,
|
projectName,
|
||||||
name,
|
name,
|
||||||
expiresInDays,
|
expiresInDays
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
|
|
@ -73,13 +73,11 @@ router.post('/', async (req, res) => {
|
||||||
finalOrgId,
|
finalOrgId,
|
||||||
name,
|
name,
|
||||||
req.apiKey!.id,
|
req.apiKey!.id,
|
||||||
expiresInDays || 90,
|
expiresInDays || 90
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(`[${new Date().toISOString()}] New API key created by admin: ${result.metadata.id} (${result.metadata.keyType}) - by: ${req.apiKey!.id}`);
|
||||||
`[${new Date().toISOString()}] New API key created by admin: ${result.metadata.id} (${result.metadata.keyType}) - by: ${req.apiKey!.id}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
apiKey: result.key,
|
apiKey: result.key,
|
||||||
|
|
@ -113,7 +111,7 @@ router.get('/', async (req, res) => {
|
||||||
const keys = await apiKeyService.listKeys();
|
const keys = await apiKeyService.listKeys();
|
||||||
|
|
||||||
// Don't expose key hashes
|
// Don't expose key hashes
|
||||||
const safeKeys = keys.map((key) => ({
|
const safeKeys = keys.map(key => ({
|
||||||
id: key.id,
|
id: key.id,
|
||||||
type: key.keyType,
|
type: key.keyType,
|
||||||
projectId: key.projectId,
|
projectId: key.projectId,
|
||||||
|
|
@ -171,4 +169,4 @@ router.delete('/:keyId', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import express from 'express';
|
import express from "express";
|
||||||
import { ApiKeyService } from '../services/ApiKeyService';
|
import { ApiKeyService } from "../services/ApiKeyService";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const apiKeyService = new ApiKeyService();
|
const apiKeyService = new ApiKeyService();
|
||||||
|
|
@ -10,21 +10,25 @@ const apiKeyService = new ApiKeyService();
|
||||||
*
|
*
|
||||||
* POST /api/bootstrap/initial-key
|
* POST /api/bootstrap/initial-key
|
||||||
*/
|
*/
|
||||||
router.post('/initial-key', async (req, res) => {
|
router.post("/initial-key", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Check if any keys already exist
|
// Check if any keys already exist
|
||||||
const hasKeys = await apiKeyService.hasAnyKeys();
|
const hasKeys = await apiKeyService.hasAnyKeys();
|
||||||
|
|
||||||
if (hasKeys) {
|
if (hasKeys) {
|
||||||
console.warn(`[${new Date().toISOString()}] Bootstrap attempt when keys already exist`);
|
console.warn(
|
||||||
|
`[${new Date().toISOString()}] Bootstrap attempt when keys already exist`,
|
||||||
|
);
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: 'Bootstrap not allowed',
|
error: "Bootstrap not allowed",
|
||||||
message: 'API keys already exist. Use /api/admin/keys to create new keys.',
|
message:
|
||||||
|
"API keys already exist. Use /api/admin/keys to create new keys.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create first master key
|
// Create first master key
|
||||||
const { key, metadata } = await apiKeyService.createMasterKey('Initial Master Key');
|
const { key, metadata } =
|
||||||
|
await apiKeyService.createMasterKey("Initial Master Key");
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[${new Date().toISOString()}] Initial master key created via bootstrap: ${metadata.id}`,
|
`[${new Date().toISOString()}] Initial master key created via bootstrap: ${metadata.id}`,
|
||||||
|
|
@ -35,13 +39,13 @@ router.post('/initial-key', async (req, res) => {
|
||||||
type: metadata.keyType,
|
type: metadata.keyType,
|
||||||
name: metadata.name,
|
name: metadata.name,
|
||||||
expiresAt: metadata.expiresAt,
|
expiresAt: metadata.expiresAt,
|
||||||
message: 'IMPORTANT: Save this key securely. You will not see it again!',
|
message: "IMPORTANT: Save this key securely. You will not see it again!",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[${new Date().toISOString()}] Bootstrap error:`, error);
|
console.error(`[${new Date().toISOString()}] Bootstrap error:`, error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: 'Bootstrap failed',
|
error: "Bootstrap failed",
|
||||||
message: 'Failed to create initial API key',
|
message: "Failed to create initial API key",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import type { Router as RouterType } from "express";
|
||||||
|
import { PromptEnhancementService } from "../services/promptEnhancement";
|
||||||
|
import { asyncHandler } from "../middleware/errorHandler";
|
||||||
|
import {
|
||||||
|
PromptEnhancementRequest,
|
||||||
|
PromptEnhancementResponse,
|
||||||
|
} from "../types/api";
|
||||||
|
import { body, validationResult } from "express-validator";
|
||||||
|
import { validateApiKey } from "../middleware/auth/validateApiKey";
|
||||||
|
import { rateLimitByApiKey } from "../middleware/auth/rateLimiter";
|
||||||
|
|
||||||
|
export const enhanceRouter: RouterType = Router();
|
||||||
|
|
||||||
|
let promptEnhancementService: PromptEnhancementService;
|
||||||
|
|
||||||
|
const validateEnhanceRequest = [
|
||||||
|
body("prompt")
|
||||||
|
.notEmpty()
|
||||||
|
.withMessage("Prompt is required")
|
||||||
|
.isLength({ min: 1, max: 5000 })
|
||||||
|
.withMessage("Prompt must be between 1 and 5000 characters")
|
||||||
|
.trim(),
|
||||||
|
|
||||||
|
body("options.imageStyle")
|
||||||
|
.optional()
|
||||||
|
.isIn([
|
||||||
|
"photorealistic",
|
||||||
|
"illustration",
|
||||||
|
"minimalist",
|
||||||
|
"sticker",
|
||||||
|
"product",
|
||||||
|
"comic",
|
||||||
|
])
|
||||||
|
.withMessage("Invalid image style"),
|
||||||
|
|
||||||
|
body("options.aspectRatio")
|
||||||
|
.optional()
|
||||||
|
.isIn(["square", "portrait", "landscape", "wide", "ultrawide"])
|
||||||
|
.withMessage("Invalid aspect ratio"),
|
||||||
|
|
||||||
|
body("options.mood")
|
||||||
|
.optional()
|
||||||
|
.isLength({ max: 100 })
|
||||||
|
.withMessage("Mood description too long")
|
||||||
|
.trim(),
|
||||||
|
|
||||||
|
body("options.lighting")
|
||||||
|
.optional()
|
||||||
|
.isLength({ max: 100 })
|
||||||
|
.withMessage("Lighting description too long")
|
||||||
|
.trim(),
|
||||||
|
|
||||||
|
body("options.cameraAngle")
|
||||||
|
.optional()
|
||||||
|
.isLength({ max: 100 })
|
||||||
|
.withMessage("Camera angle description too long")
|
||||||
|
.trim(),
|
||||||
|
|
||||||
|
body("options.outputFormat")
|
||||||
|
.optional()
|
||||||
|
.isIn(["text", "markdown", "detailed"])
|
||||||
|
.withMessage("Invalid output format"),
|
||||||
|
|
||||||
|
body("options.negativePrompts")
|
||||||
|
.optional()
|
||||||
|
.isArray({ max: 10 })
|
||||||
|
.withMessage("Too many negative prompts (max 10)"),
|
||||||
|
|
||||||
|
body("options.negativePrompts.*")
|
||||||
|
.optional()
|
||||||
|
.isLength({ max: 100 })
|
||||||
|
.withMessage("Negative prompt too long")
|
||||||
|
.trim(),
|
||||||
|
];
|
||||||
|
|
||||||
|
const logEnhanceRequest = (req: Request, _res: Response, next: Function) => {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const requestId = req.requestId;
|
||||||
|
const { prompt, options } = req.body as PromptEnhancementRequest;
|
||||||
|
|
||||||
|
console.log(`[${timestamp}] [${requestId}] POST /api/enhance`);
|
||||||
|
console.log(
|
||||||
|
`[${timestamp}] [${requestId}] Prompt length: ${prompt?.length || 0} characters`,
|
||||||
|
);
|
||||||
|
console.log(`[${timestamp}] [${requestId}] Options:`, options || "none");
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
enhanceRouter.post(
|
||||||
|
"/enhance",
|
||||||
|
// Authentication middleware
|
||||||
|
validateApiKey,
|
||||||
|
rateLimitByApiKey,
|
||||||
|
|
||||||
|
validateEnhanceRequest,
|
||||||
|
logEnhanceRequest,
|
||||||
|
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
if (!promptEnhancementService) {
|
||||||
|
const apiKey = process.env["GEMINI_API_KEY"];
|
||||||
|
if (!apiKey) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
originalPrompt: "",
|
||||||
|
error: "Server configuration error: GEMINI_API_KEY not configured",
|
||||||
|
} as PromptEnhancementResponse);
|
||||||
|
}
|
||||||
|
promptEnhancementService = new PromptEnhancementService(apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
console.log(
|
||||||
|
`[${new Date().toISOString()}] [${req.requestId}] Validation failed:`,
|
||||||
|
errors.array(),
|
||||||
|
);
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
originalPrompt: req.body.prompt || "",
|
||||||
|
error: `Validation failed: ${errors
|
||||||
|
.array()
|
||||||
|
.map((e) => e.msg)
|
||||||
|
.join(", ")}`,
|
||||||
|
} as PromptEnhancementResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const requestId = req.requestId;
|
||||||
|
const { prompt, options } = req.body as PromptEnhancementRequest;
|
||||||
|
|
||||||
|
console.log(`[${timestamp}] [${requestId}] Starting prompt enhancement`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Extract orgId and projectId from validated API key
|
||||||
|
const orgId = req.apiKey?.organizationSlug || "unknown";
|
||||||
|
const projectId = req.apiKey?.projectSlug || "unknown";
|
||||||
|
|
||||||
|
const result = await promptEnhancementService.enhancePrompt(
|
||||||
|
prompt,
|
||||||
|
options || {},
|
||||||
|
{
|
||||||
|
orgId,
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[${timestamp}] [${requestId}] Enhancement completed:`, {
|
||||||
|
success: result.success,
|
||||||
|
detectedLanguage: result.detectedLanguage,
|
||||||
|
appliedTemplate: result.appliedTemplate,
|
||||||
|
enhancementsCount: result.metadata?.enhancements.length || 0,
|
||||||
|
hasError: !!result.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const successResponse: PromptEnhancementResponse = {
|
||||||
|
success: true,
|
||||||
|
originalPrompt: result.originalPrompt,
|
||||||
|
enhancedPrompt: result.enhancedPrompt!,
|
||||||
|
...(result.detectedLanguage && {
|
||||||
|
detectedLanguage: result.detectedLanguage,
|
||||||
|
}),
|
||||||
|
...(result.appliedTemplate && {
|
||||||
|
appliedTemplate: result.appliedTemplate,
|
||||||
|
}),
|
||||||
|
...(result.metadata && { metadata: result.metadata }),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[${timestamp}] [${requestId}] Sending success response`);
|
||||||
|
return res.status(200).json(successResponse);
|
||||||
|
} else {
|
||||||
|
const errorResponse: PromptEnhancementResponse = {
|
||||||
|
success: false,
|
||||||
|
originalPrompt: result.originalPrompt,
|
||||||
|
error: result.error || "Unknown error occurred",
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[${timestamp}] [${requestId}] Sending error response: ${result.error}`,
|
||||||
|
);
|
||||||
|
return res.status(500).json(errorResponse);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`[${timestamp}] [${requestId}] Unhandled error in enhance endpoint:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
|
||||||
|
const errorResponse: PromptEnhancementResponse = {
|
||||||
|
success: false,
|
||||||
|
originalPrompt: prompt,
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "Unknown error occurred",
|
||||||
|
};
|
||||||
|
|
||||||
|
return res.status(500).json(errorResponse);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
import { Response, Router } from "express";
|
||||||
|
import type { Router as RouterType } from "express";
|
||||||
|
import { ImageGenService } from "../services/ImageGenService";
|
||||||
|
import {
|
||||||
|
uploadReferenceImages,
|
||||||
|
handleUploadErrors,
|
||||||
|
} from "../middleware/upload";
|
||||||
|
import {
|
||||||
|
validateGenerateRequest,
|
||||||
|
logRequestDetails,
|
||||||
|
} from "../middleware/validation";
|
||||||
|
import {
|
||||||
|
autoEnhancePrompt,
|
||||||
|
logEnhancementResult,
|
||||||
|
} from "../middleware/promptEnhancement";
|
||||||
|
import { asyncHandler } from "../middleware/errorHandler";
|
||||||
|
import { validateApiKey } from "../middleware/auth/validateApiKey";
|
||||||
|
import { requireProjectKey } from "../middleware/auth/requireProjectKey";
|
||||||
|
import { rateLimitByApiKey } from "../middleware/auth/rateLimiter";
|
||||||
|
import { GenerateImageResponse } from "../types/api";
|
||||||
|
// Create router
|
||||||
|
export const generateRouter: RouterType = Router();
|
||||||
|
|
||||||
|
// Initialize ImageGenService (will be created in the route handler to avoid circular dependency)
|
||||||
|
let imageGenService: ImageGenService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/generate - Generate image from text prompt with optional reference images
|
||||||
|
*/
|
||||||
|
generateRouter.post(
|
||||||
|
"/generate",
|
||||||
|
// Authentication middleware
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
rateLimitByApiKey,
|
||||||
|
|
||||||
|
// File upload middleware
|
||||||
|
uploadReferenceImages,
|
||||||
|
handleUploadErrors,
|
||||||
|
|
||||||
|
// Validation middleware
|
||||||
|
logRequestDetails,
|
||||||
|
validateGenerateRequest,
|
||||||
|
|
||||||
|
// Auto-enhancement middleware (optional)
|
||||||
|
autoEnhancePrompt,
|
||||||
|
logEnhancementResult,
|
||||||
|
|
||||||
|
// Main handler
|
||||||
|
asyncHandler(async (req: any, res: Response) => {
|
||||||
|
// Initialize service if not already done
|
||||||
|
if (!imageGenService) {
|
||||||
|
const apiKey = process.env["GEMINI_API_KEY"];
|
||||||
|
if (!apiKey) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Server configuration error",
|
||||||
|
error: "GEMINI_API_KEY not configured",
|
||||||
|
} as GenerateImageResponse);
|
||||||
|
}
|
||||||
|
imageGenService = new ImageGenService(apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const requestId = req.requestId;
|
||||||
|
const { prompt, filename, aspectRatio, meta } = req.body;
|
||||||
|
const files = (req.files as Express.Multer.File[]) || [];
|
||||||
|
|
||||||
|
// Extract org/project slugs from validated API key
|
||||||
|
const orgId = req.apiKey?.organizationSlug || undefined;
|
||||||
|
const projectId = req.apiKey?.projectSlug!; // Guaranteed by requireProjectKey middleware
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[${timestamp}] [${requestId}] Starting image generation process for org:${orgId}, project:${projectId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate reference images if provided
|
||||||
|
if (files.length > 0) {
|
||||||
|
const validation = ImageGenService.validateReferenceImages(files);
|
||||||
|
if (!validation.valid) {
|
||||||
|
console.log(
|
||||||
|
`[${timestamp}] [${requestId}] Reference image validation failed: ${validation.error}`,
|
||||||
|
);
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "Reference image validation failed",
|
||||||
|
error: validation.error,
|
||||||
|
} as GenerateImageResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[${timestamp}] [${requestId}] Reference images validation passed`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert files to reference images
|
||||||
|
const referenceImages =
|
||||||
|
files.length > 0
|
||||||
|
? ImageGenService.convertFilesToReferenceImages(files)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Generate the image
|
||||||
|
console.log(
|
||||||
|
`[${timestamp}] [${requestId}] Calling ImageGenService.generateImage()`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await imageGenService.generateImage({
|
||||||
|
prompt,
|
||||||
|
filename,
|
||||||
|
...(aspectRatio && { aspectRatio }),
|
||||||
|
orgId,
|
||||||
|
projectId,
|
||||||
|
...(referenceImages && { referenceImages }),
|
||||||
|
...(meta && { meta }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log the result
|
||||||
|
console.log(`[${timestamp}] [${requestId}] Image generation completed:`, {
|
||||||
|
success: result.success,
|
||||||
|
model: result.model,
|
||||||
|
filename: result.filename,
|
||||||
|
hasError: !!result.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send response
|
||||||
|
if (result.success) {
|
||||||
|
const successResponse: GenerateImageResponse = {
|
||||||
|
success: true,
|
||||||
|
message: "Image generated successfully",
|
||||||
|
data: {
|
||||||
|
filename: result.filename!,
|
||||||
|
filepath: result.filepath!,
|
||||||
|
...(result.description && { description: result.description }),
|
||||||
|
model: result.model,
|
||||||
|
generatedAt: timestamp,
|
||||||
|
...(req.enhancedPrompt && {
|
||||||
|
promptEnhancement: {
|
||||||
|
originalPrompt: req.originalPrompt,
|
||||||
|
enhancedPrompt: req.enhancedPrompt,
|
||||||
|
detectedLanguage: req.enhancementMetadata?.detectedLanguage,
|
||||||
|
appliedTemplate: req.enhancementMetadata?.appliedTemplate,
|
||||||
|
enhancements: req.enhancementMetadata?.enhancements || [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[${timestamp}] [${requestId}] Sending success response`);
|
||||||
|
return res.status(200).json(successResponse);
|
||||||
|
} else {
|
||||||
|
const errorResponse: GenerateImageResponse = {
|
||||||
|
success: false,
|
||||||
|
message: "Image generation failed",
|
||||||
|
error: result.error || "Unknown error occurred",
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[${timestamp}] [${requestId}] Sending error response: ${result.error}`,
|
||||||
|
);
|
||||||
|
return res.status(500).json(errorResponse);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`[${timestamp}] [${requestId}] Unhandled error in generate endpoint:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
|
||||||
|
const errorResponse: GenerateImageResponse = {
|
||||||
|
success: false,
|
||||||
|
message: "Image generation failed",
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "Unknown error occurred",
|
||||||
|
};
|
||||||
|
|
||||||
|
return res.status(500).json(errorResponse);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from "express";
|
||||||
import { StorageFactory } from '../services/StorageFactory';
|
import { StorageFactory } from "../services/StorageFactory";
|
||||||
import { asyncHandler } from '../middleware/errorHandler';
|
import { asyncHandler } from "../middleware/errorHandler";
|
||||||
|
|
||||||
export const imagesRouter = Router();
|
export const imagesRouter = Router();
|
||||||
|
|
||||||
|
|
@ -9,15 +9,15 @@ export const imagesRouter = Router();
|
||||||
* Serves images via presigned URLs (redirect approach)
|
* Serves images via presigned URLs (redirect approach)
|
||||||
*/
|
*/
|
||||||
imagesRouter.get(
|
imagesRouter.get(
|
||||||
'/images/:orgId/:projectId/:category/:filename',
|
"/images/:orgId/:projectId/:category/:filename",
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
const { orgId, projectId, category, filename } = req.params;
|
const { orgId, projectId, category, filename } = req.params;
|
||||||
|
|
||||||
// Validate category
|
// Validate category
|
||||||
if (!['uploads', 'generated', 'references'].includes(category)) {
|
if (!["uploads", "generated", "references"].includes(category)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Invalid category',
|
message: "Invalid category",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,36 +28,36 @@ imagesRouter.get(
|
||||||
const exists = await storageService.fileExists(
|
const exists = await storageService.fileExists(
|
||||||
orgId,
|
orgId,
|
||||||
projectId,
|
projectId,
|
||||||
category as 'uploads' | 'generated' | 'references',
|
category as "uploads" | "generated" | "references",
|
||||||
filename,
|
filename,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'File not found',
|
message: "File not found",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine content type from filename
|
// Determine content type from filename
|
||||||
const ext = filename.toLowerCase().split('.').pop();
|
const ext = filename.toLowerCase().split(".").pop();
|
||||||
const contentType =
|
const contentType =
|
||||||
{
|
{
|
||||||
png: 'image/png',
|
png: "image/png",
|
||||||
jpg: 'image/jpeg',
|
jpg: "image/jpeg",
|
||||||
jpeg: 'image/jpeg',
|
jpeg: "image/jpeg",
|
||||||
gif: 'image/gif',
|
gif: "image/gif",
|
||||||
webp: 'image/webp',
|
webp: "image/webp",
|
||||||
svg: 'image/svg+xml',
|
svg: "image/svg+xml",
|
||||||
}[ext || ''] || 'application/octet-stream';
|
}[ext || ""] || "application/octet-stream";
|
||||||
|
|
||||||
// Set headers for optimal caching and performance
|
// Set headers for optimal caching and performance
|
||||||
res.setHeader('Content-Type', contentType);
|
res.setHeader("Content-Type", contentType);
|
||||||
res.setHeader('Cache-Control', 'public, max-age=86400, immutable'); // 24 hours + immutable
|
res.setHeader("Cache-Control", "public, max-age=86400, immutable"); // 24 hours + immutable
|
||||||
res.setHeader('ETag', `"${orgId}-${projectId}-${filename}"`); // Simple ETag
|
res.setHeader("ETag", `"${orgId}-${projectId}-${filename}"`); // Simple ETag
|
||||||
|
|
||||||
// Handle conditional requests (304 Not Modified)
|
// Handle conditional requests (304 Not Modified)
|
||||||
const ifNoneMatch = req.headers['if-none-match'];
|
const ifNoneMatch = req.headers["if-none-match"];
|
||||||
if (ifNoneMatch === `"${orgId}-${projectId}-${filename}"`) {
|
if (ifNoneMatch === `"${orgId}-${projectId}-${filename}"`) {
|
||||||
return res.status(304).end(); // Not Modified
|
return res.status(304).end(); // Not Modified
|
||||||
}
|
}
|
||||||
|
|
@ -66,17 +66,17 @@ imagesRouter.get(
|
||||||
const fileStream = await storageService.streamFile(
|
const fileStream = await storageService.streamFile(
|
||||||
orgId,
|
orgId,
|
||||||
projectId,
|
projectId,
|
||||||
category as 'uploads' | 'generated' | 'references',
|
category as "uploads" | "generated" | "references",
|
||||||
filename,
|
filename,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle stream errors
|
// Handle stream errors
|
||||||
fileStream.on('error', (streamError) => {
|
fileStream.on("error", (streamError) => {
|
||||||
console.error('Stream error:', streamError);
|
console.error("Stream error:", streamError);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Error streaming file',
|
message: "Error streaming file",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -84,10 +84,10 @@ imagesRouter.get(
|
||||||
// Stream the file without loading into memory
|
// Stream the file without loading into memory
|
||||||
fileStream.pipe(res);
|
fileStream.pipe(res);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to stream file:', error);
|
console.error("Failed to stream file:", error);
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'File not found',
|
message: "File not found",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
@ -98,15 +98,15 @@ imagesRouter.get(
|
||||||
* Returns a presigned URL instead of redirecting
|
* Returns a presigned URL instead of redirecting
|
||||||
*/
|
*/
|
||||||
imagesRouter.get(
|
imagesRouter.get(
|
||||||
'/images/url/:orgId/:projectId/:category/:filename',
|
"/images/url/:orgId/:projectId/:category/:filename",
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
const { orgId, projectId, category, filename } = req.params;
|
const { orgId, projectId, category, filename } = req.params;
|
||||||
const { expiry = '3600' } = req.query; // Default 1 hour
|
const { expiry = "3600" } = req.query; // Default 1 hour
|
||||||
|
|
||||||
if (!['uploads', 'generated', 'references'].includes(category)) {
|
if (!["uploads", "generated", "references"].includes(category)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Invalid category',
|
message: "Invalid category",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,7 +116,7 @@ imagesRouter.get(
|
||||||
const presignedUrl = await storageService.getPresignedDownloadUrl(
|
const presignedUrl = await storageService.getPresignedDownloadUrl(
|
||||||
orgId,
|
orgId,
|
||||||
projectId,
|
projectId,
|
||||||
category as 'uploads' | 'generated' | 'references',
|
category as "uploads" | "generated" | "references",
|
||||||
filename,
|
filename,
|
||||||
parseInt(expiry as string, 10),
|
parseInt(expiry as string, 10),
|
||||||
);
|
);
|
||||||
|
|
@ -127,10 +127,10 @@ imagesRouter.get(
|
||||||
expiresIn: parseInt(expiry as string, 10),
|
expiresIn: parseInt(expiry as string, 10),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to generate presigned URL:', error);
|
console.error("Failed to generate presigned URL:", error);
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'File not found or access denied',
|
message: "File not found or access denied",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
import { Response, Router } from 'express';
|
import { Response, Router } from "express";
|
||||||
import type { Router as RouterType } from 'express';
|
import type { Router as RouterType } from "express";
|
||||||
import { ImageGenService } from '../services/ImageGenService';
|
import { ImageGenService } from "../services/ImageGenService";
|
||||||
import { validateTextToImageRequest, logTextToImageRequest } from '../middleware/jsonValidation';
|
import {
|
||||||
import { autoEnhancePrompt, logEnhancementResult } from '../middleware/promptEnhancement';
|
validateTextToImageRequest,
|
||||||
import { asyncHandler } from '../middleware/errorHandler';
|
logTextToImageRequest,
|
||||||
import { validateApiKey } from '../middleware/auth/validateApiKey';
|
} from "../middleware/jsonValidation";
|
||||||
import { requireProjectKey } from '../middleware/auth/requireProjectKey';
|
import {
|
||||||
import { rateLimitByApiKey } from '../middleware/auth/rateLimiter';
|
autoEnhancePrompt,
|
||||||
import { GenerateImageResponse } from '../types/api';
|
logEnhancementResult,
|
||||||
|
} from "../middleware/promptEnhancement";
|
||||||
|
import { asyncHandler } from "../middleware/errorHandler";
|
||||||
|
import { validateApiKey } from "../middleware/auth/validateApiKey";
|
||||||
|
import { requireProjectKey } from "../middleware/auth/requireProjectKey";
|
||||||
|
import { rateLimitByApiKey } from "../middleware/auth/rateLimiter";
|
||||||
|
import { GenerateImageResponse } from "../types/api";
|
||||||
|
|
||||||
export const textToImageRouter: RouterType = Router();
|
export const textToImageRouter: RouterType = Router();
|
||||||
|
|
||||||
|
|
@ -17,7 +23,7 @@ let imageGenService: ImageGenService;
|
||||||
* POST /api/text-to-image - Generate image from text prompt only (JSON)
|
* POST /api/text-to-image - Generate image from text prompt only (JSON)
|
||||||
*/
|
*/
|
||||||
textToImageRouter.post(
|
textToImageRouter.post(
|
||||||
'/text-to-image',
|
"/text-to-image",
|
||||||
// Authentication middleware
|
// Authentication middleware
|
||||||
validateApiKey,
|
validateApiKey,
|
||||||
requireProjectKey,
|
requireProjectKey,
|
||||||
|
|
@ -35,12 +41,12 @@ textToImageRouter.post(
|
||||||
asyncHandler(async (req: any, res: Response) => {
|
asyncHandler(async (req: any, res: Response) => {
|
||||||
// Initialize service if not already done
|
// Initialize service if not already done
|
||||||
if (!imageGenService) {
|
if (!imageGenService) {
|
||||||
const apiKey = process.env['GEMINI_API_KEY'];
|
const apiKey = process.env["GEMINI_API_KEY"];
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Server configuration error',
|
message: "Server configuration error",
|
||||||
error: 'GEMINI_API_KEY not configured',
|
error: "GEMINI_API_KEY not configured",
|
||||||
} as GenerateImageResponse);
|
} as GenerateImageResponse);
|
||||||
}
|
}
|
||||||
imageGenService = new ImageGenService(apiKey);
|
imageGenService = new ImageGenService(apiKey);
|
||||||
|
|
@ -74,18 +80,21 @@ textToImageRouter.post(
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log the result
|
// Log the result
|
||||||
console.log(`[${timestamp}] [${requestId}] Text-to-image generation completed:`, {
|
console.log(
|
||||||
success: result.success,
|
`[${timestamp}] [${requestId}] Text-to-image generation completed:`,
|
||||||
model: result.model,
|
{
|
||||||
filename: result.filename,
|
success: result.success,
|
||||||
hasError: !!result.error,
|
model: result.model,
|
||||||
});
|
filename: result.filename,
|
||||||
|
hasError: !!result.error,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Send response
|
// Send response
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const successResponse: GenerateImageResponse = {
|
const successResponse: GenerateImageResponse = {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Image generated successfully',
|
message: "Image generated successfully",
|
||||||
data: {
|
data: {
|
||||||
filename: result.filename!,
|
filename: result.filename!,
|
||||||
filepath: result.filepath!,
|
filepath: result.filepath!,
|
||||||
|
|
@ -111,11 +120,13 @@ textToImageRouter.post(
|
||||||
} else {
|
} else {
|
||||||
const errorResponse: GenerateImageResponse = {
|
const errorResponse: GenerateImageResponse = {
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Image generation failed',
|
message: "Image generation failed",
|
||||||
error: result.error || 'Unknown error occurred',
|
error: result.error || "Unknown error occurred",
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`[${timestamp}] [${requestId}] Sending error response: ${result.error}`);
|
console.log(
|
||||||
|
`[${timestamp}] [${requestId}] Sending error response: ${result.error}`,
|
||||||
|
);
|
||||||
return res.status(500).json(errorResponse);
|
return res.status(500).json(errorResponse);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -126,8 +137,9 @@ textToImageRouter.post(
|
||||||
|
|
||||||
const errorResponse: GenerateImageResponse = {
|
const errorResponse: GenerateImageResponse = {
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Image generation failed',
|
message: "Image generation failed",
|
||||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
error:
|
||||||
|
error instanceof Error ? error.message : "Unknown error occurred",
|
||||||
};
|
};
|
||||||
|
|
||||||
return res.status(500).json(errorResponse);
|
return res.status(500).json(errorResponse);
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,13 @@ import fs from 'fs';
|
||||||
|
|
||||||
// Ensure required directories exist
|
// Ensure required directories exist
|
||||||
const ensureDirectoriesExist = () => {
|
const ensureDirectoriesExist = () => {
|
||||||
const directories = [appConfig.resultsDir, appConfig.uploadsDir, './logs'];
|
const directories = [
|
||||||
|
appConfig.resultsDir,
|
||||||
|
appConfig.uploadsDir,
|
||||||
|
'./logs'
|
||||||
|
];
|
||||||
|
|
||||||
directories.forEach((dir) => {
|
directories.forEach(dir => {
|
||||||
if (!fs.existsSync(dir)) {
|
if (!fs.existsSync(dir)) {
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
console.log(`[${new Date().toISOString()}] Created directory: ${dir}`);
|
console.log(`[${new Date().toISOString()}] Created directory: ${dir}`);
|
||||||
|
|
@ -28,10 +32,7 @@ const validateEnvironment = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
log('INFO', 'Environment validation passed');
|
log('INFO', 'Environment validation passed');
|
||||||
log(
|
log('INFO', `Server configuration: port=${appConfig.port}, maxFileSize=${appConfig.maxFileSize}bytes, maxFiles=${appConfig.maxFiles}`);
|
||||||
'INFO',
|
|
||||||
`Server configuration: port=${appConfig.port}, maxFileSize=${appConfig.maxFileSize}bytes, maxFiles=${appConfig.maxFiles}`,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
|
|
@ -75,6 +76,7 @@ const startServer = async () => {
|
||||||
|
|
||||||
// Setup graceful shutdown
|
// Setup graceful shutdown
|
||||||
setupGracefulShutdown(server);
|
setupGracefulShutdown(server);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log('ERROR', `Failed to start server: ${error instanceof Error ? error.message : error}`);
|
log('ERROR', `Failed to start server: ${error instanceof Error ? error.message : error}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|
@ -82,4 +84,4 @@ const startServer = async () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start the server
|
// Start the server
|
||||||
startServer();
|
startServer();
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import crypto from 'crypto';
|
import crypto from "crypto";
|
||||||
import { db } from '../db';
|
import { db } from "../db";
|
||||||
import { apiKeys, organizations, projects, type ApiKey, type NewApiKey } from '@banatie/database';
|
import { apiKeys, organizations, projects, type ApiKey, type NewApiKey } from "@banatie/database";
|
||||||
import { eq, and, desc } from 'drizzle-orm';
|
import { eq, and, desc } from "drizzle-orm";
|
||||||
|
|
||||||
// Extended API key type with slugs for storage paths
|
// Extended API key type with slugs for storage paths
|
||||||
export interface ApiKeyWithSlugs extends ApiKey {
|
export interface ApiKeyWithSlugs extends ApiKey {
|
||||||
|
|
@ -19,12 +19,12 @@ export class ApiKeyService {
|
||||||
keyHash: string;
|
keyHash: string;
|
||||||
keyPrefix: string;
|
keyPrefix: string;
|
||||||
} {
|
} {
|
||||||
const secret = crypto.randomBytes(32).toString('hex'); // 64 chars
|
const secret = crypto.randomBytes(32).toString("hex"); // 64 chars
|
||||||
const keyPrefix = 'bnt_';
|
const keyPrefix = "bnt_";
|
||||||
const fullKey = keyPrefix + secret;
|
const fullKey = keyPrefix + secret;
|
||||||
|
|
||||||
// Hash for storage (SHA-256)
|
// Hash for storage (SHA-256)
|
||||||
const keyHash = crypto.createHash('sha256').update(fullKey).digest('hex');
|
const keyHash = crypto.createHash("sha256").update(fullKey).digest("hex");
|
||||||
|
|
||||||
return { fullKey, keyHash, keyPrefix };
|
return { fullKey, keyHash, keyPrefix };
|
||||||
}
|
}
|
||||||
|
|
@ -43,10 +43,10 @@ export class ApiKeyService {
|
||||||
.values({
|
.values({
|
||||||
keyHash,
|
keyHash,
|
||||||
keyPrefix,
|
keyPrefix,
|
||||||
keyType: 'master',
|
keyType: "master",
|
||||||
projectId: null,
|
projectId: null,
|
||||||
scopes: ['*'], // Full access
|
scopes: ["*"], // Full access
|
||||||
name: name || 'Master Key',
|
name: name || "Master Key",
|
||||||
expiresAt: null, // Never expires
|
expiresAt: null, // Never expires
|
||||||
createdBy: createdBy || null,
|
createdBy: createdBy || null,
|
||||||
})
|
})
|
||||||
|
|
@ -79,10 +79,10 @@ export class ApiKeyService {
|
||||||
.values({
|
.values({
|
||||||
keyHash,
|
keyHash,
|
||||||
keyPrefix,
|
keyPrefix,
|
||||||
keyType: 'project',
|
keyType: "project",
|
||||||
projectId,
|
projectId,
|
||||||
organizationId: organizationId || null,
|
organizationId: organizationId || null,
|
||||||
scopes: ['generate', 'read'],
|
scopes: ["generate", "read"],
|
||||||
name: name || `Project Key - ${projectId}`,
|
name: name || `Project Key - ${projectId}`,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
createdBy: createdBy || null,
|
createdBy: createdBy || null,
|
||||||
|
|
@ -102,12 +102,15 @@ export class ApiKeyService {
|
||||||
* Returns API key with organization and project slugs for storage paths
|
* Returns API key with organization and project slugs for storage paths
|
||||||
*/
|
*/
|
||||||
async validateKey(providedKey: string): Promise<ApiKeyWithSlugs | null> {
|
async validateKey(providedKey: string): Promise<ApiKeyWithSlugs | null> {
|
||||||
if (!providedKey || !providedKey.startsWith('bnt_')) {
|
if (!providedKey || !providedKey.startsWith("bnt_")) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash the provided key
|
// Hash the provided key
|
||||||
const keyHash = crypto.createHash('sha256').update(providedKey).digest('hex');
|
const keyHash = crypto
|
||||||
|
.createHash("sha256")
|
||||||
|
.update(providedKey)
|
||||||
|
.digest("hex");
|
||||||
|
|
||||||
// Find in database with left joins to get slugs
|
// Find in database with left joins to get slugs
|
||||||
const [result] = await db
|
const [result] = await db
|
||||||
|
|
@ -157,7 +160,10 @@ export class ApiKeyService {
|
||||||
.where(eq(apiKeys.id, result.id))
|
.where(eq(apiKeys.id, result.id))
|
||||||
.execute()
|
.execute()
|
||||||
.catch((err) =>
|
.catch((err) =>
|
||||||
console.error(`[${new Date().toISOString()}] Failed to update lastUsedAt:`, err),
|
console.error(
|
||||||
|
`[${new Date().toISOString()}] Failed to update lastUsedAt:`,
|
||||||
|
err,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return result as ApiKeyWithSlugs;
|
return result as ApiKeyWithSlugs;
|
||||||
|
|
@ -201,7 +207,11 @@ export class ApiKeyService {
|
||||||
* Get or create organization by slug
|
* Get or create organization by slug
|
||||||
* If organization doesn't exist, create it with provided name (or use slug as name)
|
* If organization doesn't exist, create it with provided name (or use slug as name)
|
||||||
*/
|
*/
|
||||||
async getOrCreateOrganization(slug: string, name?: string, email?: string): Promise<string> {
|
async getOrCreateOrganization(
|
||||||
|
slug: string,
|
||||||
|
name?: string,
|
||||||
|
email?: string,
|
||||||
|
): Promise<string> {
|
||||||
// Try to find existing organization
|
// Try to find existing organization
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select({ id: organizations.id })
|
.select({ id: organizations.id })
|
||||||
|
|
@ -223,7 +233,9 @@ export class ApiKeyService {
|
||||||
})
|
})
|
||||||
.returning({ id: organizations.id });
|
.returning({ id: organizations.id });
|
||||||
|
|
||||||
console.log(`[${new Date().toISOString()}] Organization created: ${newOrg?.id} - ${slug}`);
|
console.log(
|
||||||
|
`[${new Date().toISOString()}] Organization created: ${newOrg?.id} - ${slug}`,
|
||||||
|
);
|
||||||
|
|
||||||
return newOrg!.id;
|
return newOrg!.id;
|
||||||
}
|
}
|
||||||
|
|
@ -232,12 +244,21 @@ export class ApiKeyService {
|
||||||
* Get or create project by slug within an organization
|
* Get or create project by slug within an organization
|
||||||
* If project doesn't exist, create it with provided name (or use slug as name)
|
* If project doesn't exist, create it with provided name (or use slug as name)
|
||||||
*/
|
*/
|
||||||
async getOrCreateProject(organizationId: string, slug: string, name?: string): Promise<string> {
|
async getOrCreateProject(
|
||||||
|
organizationId: string,
|
||||||
|
slug: string,
|
||||||
|
name?: string,
|
||||||
|
): Promise<string> {
|
||||||
// Try to find existing project
|
// Try to find existing project
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select({ id: projects.id })
|
.select({ id: projects.id })
|
||||||
.from(projects)
|
.from(projects)
|
||||||
.where(and(eq(projects.organizationId, organizationId), eq(projects.slug, slug)))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(projects.organizationId, organizationId),
|
||||||
|
eq(projects.slug, slug),
|
||||||
|
),
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,23 @@
|
||||||
import { GoogleGenAI } from '@google/genai';
|
import { GoogleGenAI } from "@google/genai";
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const mime = require('mime') as any;
|
const mime = require("mime") as any;
|
||||||
import {
|
import {
|
||||||
ImageGenerationOptions,
|
ImageGenerationOptions,
|
||||||
ImageGenerationResult,
|
ImageGenerationResult,
|
||||||
ReferenceImage,
|
ReferenceImage,
|
||||||
GeneratedImageData,
|
GeneratedImageData,
|
||||||
GeminiParams,
|
GeminiParams,
|
||||||
} from '../types/api';
|
} from "../types/api";
|
||||||
import { StorageFactory } from './StorageFactory';
|
import { StorageFactory } from "./StorageFactory";
|
||||||
import { TTILogger, TTILogEntry } from './TTILogger';
|
import { TTILogger, TTILogEntry } from "./TTILogger";
|
||||||
import { NetworkErrorDetector } from '../utils/NetworkErrorDetector';
|
|
||||||
|
|
||||||
export class ImageGenService {
|
export class ImageGenService {
|
||||||
private ai: GoogleGenAI;
|
private ai: GoogleGenAI;
|
||||||
private primaryModel = 'gemini-2.5-flash-image';
|
private primaryModel = "gemini-2.5-flash-image";
|
||||||
|
|
||||||
constructor(apiKey: string) {
|
constructor(apiKey: string) {
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error('Gemini API key is required');
|
throw new Error("Gemini API key is required");
|
||||||
}
|
}
|
||||||
this.ai = new GoogleGenAI({ apiKey });
|
this.ai = new GoogleGenAI({ apiKey });
|
||||||
}
|
}
|
||||||
|
|
@ -27,13 +26,16 @@ export class ImageGenService {
|
||||||
* Generate an image from text prompt with optional reference images
|
* Generate an image from text prompt with optional reference images
|
||||||
* This method separates image generation from storage for clear error handling
|
* This method separates image generation from storage for clear error handling
|
||||||
*/
|
*/
|
||||||
async generateImage(options: ImageGenerationOptions): Promise<ImageGenerationResult> {
|
async generateImage(
|
||||||
|
options: ImageGenerationOptions,
|
||||||
|
): Promise<ImageGenerationResult> {
|
||||||
const { prompt, filename, referenceImages, aspectRatio, orgId, projectId, meta } = options;
|
const { prompt, filename, referenceImages, aspectRatio, orgId, projectId, meta } = options;
|
||||||
|
|
||||||
// Use default values if not provided
|
// Use default values if not provided
|
||||||
const finalOrgId = orgId || process.env['DEFAULT_ORG_ID'] || 'default';
|
const finalOrgId = orgId || process.env["DEFAULT_ORG_ID"] || "default";
|
||||||
const finalProjectId = projectId || process.env['DEFAULT_PROJECT_ID'] || 'main';
|
const finalProjectId =
|
||||||
const finalAspectRatio = aspectRatio || '1:1'; // Default to square
|
projectId || process.env["DEFAULT_PROJECT_ID"] || "main";
|
||||||
|
const finalAspectRatio = aspectRatio || "1:1"; // Default to square
|
||||||
|
|
||||||
// Step 1: Generate image from Gemini AI
|
// Step 1: Generate image from Gemini AI
|
||||||
let generatedData: GeneratedImageData;
|
let generatedData: GeneratedImageData;
|
||||||
|
|
@ -54,8 +56,9 @@ export class ImageGenService {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
model: this.primaryModel,
|
model: this.primaryModel,
|
||||||
error: error instanceof Error ? error.message : 'Image generation failed',
|
error:
|
||||||
errorType: 'generation',
|
error instanceof Error ? error.message : "Image generation failed",
|
||||||
|
errorType: "generation",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,7 +69,7 @@ export class ImageGenService {
|
||||||
const uploadResult = await storageService.uploadFile(
|
const uploadResult = await storageService.uploadFile(
|
||||||
finalOrgId,
|
finalOrgId,
|
||||||
finalProjectId,
|
finalProjectId,
|
||||||
'generated',
|
"generated",
|
||||||
finalFilename,
|
finalFilename,
|
||||||
generatedData.buffer,
|
generatedData.buffer,
|
||||||
generatedData.mimeType,
|
generatedData.mimeType,
|
||||||
|
|
@ -90,8 +93,8 @@ export class ImageGenService {
|
||||||
success: false,
|
success: false,
|
||||||
model: this.primaryModel,
|
model: this.primaryModel,
|
||||||
geminiParams,
|
geminiParams,
|
||||||
error: `Image generated successfully but storage failed: ${uploadResult.error || 'Unknown storage error'}`,
|
error: `Image generated successfully but storage failed: ${uploadResult.error || "Unknown storage error"}`,
|
||||||
errorType: 'storage',
|
errorType: "storage",
|
||||||
generatedImageData: generatedData,
|
generatedImageData: generatedData,
|
||||||
...(generatedData.description && {
|
...(generatedData.description && {
|
||||||
description: generatedData.description,
|
description: generatedData.description,
|
||||||
|
|
@ -104,8 +107,8 @@ export class ImageGenService {
|
||||||
success: false,
|
success: false,
|
||||||
model: this.primaryModel,
|
model: this.primaryModel,
|
||||||
geminiParams,
|
geminiParams,
|
||||||
error: `Image generated successfully but storage failed: ${error instanceof Error ? error.message : 'Unknown storage error'}`,
|
error: `Image generated successfully but storage failed: ${error instanceof Error ? error.message : "Unknown storage error"}`,
|
||||||
errorType: 'storage',
|
errorType: "storage",
|
||||||
generatedImageData: generatedData,
|
generatedImageData: generatedData,
|
||||||
...(generatedData.description && {
|
...(generatedData.description && {
|
||||||
description: generatedData.description,
|
description: generatedData.description,
|
||||||
|
|
@ -137,7 +140,7 @@ export class ImageGenService {
|
||||||
contentParts.push({
|
contentParts.push({
|
||||||
inlineData: {
|
inlineData: {
|
||||||
mimeType: refImage.mimetype,
|
mimeType: refImage.mimetype,
|
||||||
data: refImage.buffer.toString('base64'),
|
data: refImage.buffer.toString("base64"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -152,13 +155,13 @@ export class ImageGenService {
|
||||||
// These exact objects will be passed to both SDK and logger
|
// These exact objects will be passed to both SDK and logger
|
||||||
const contents = [
|
const contents = [
|
||||||
{
|
{
|
||||||
role: 'user' as const,
|
role: "user" as const,
|
||||||
parts: contentParts,
|
parts: contentParts,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
responseModalities: ['IMAGE', 'TEXT'],
|
responseModalities: ["IMAGE", "TEXT"],
|
||||||
imageConfig: {
|
imageConfig: {
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
},
|
},
|
||||||
|
|
@ -169,7 +172,7 @@ export class ImageGenService {
|
||||||
model: this.primaryModel,
|
model: this.primaryModel,
|
||||||
config,
|
config,
|
||||||
contentsStructure: {
|
contentsStructure: {
|
||||||
role: 'user',
|
role: "user",
|
||||||
partsCount: contentParts.length,
|
partsCount: contentParts.length,
|
||||||
hasReferenceImages: !!(referenceImages && referenceImages.length > 0),
|
hasReferenceImages: !!(referenceImages && referenceImages.length > 0),
|
||||||
},
|
},
|
||||||
|
|
@ -206,8 +209,12 @@ export class ImageGenService {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse response
|
// Parse response
|
||||||
if (!response.candidates || !response.candidates[0] || !response.candidates[0].content) {
|
if (
|
||||||
throw new Error('No response received from Gemini AI');
|
!response.candidates ||
|
||||||
|
!response.candidates[0] ||
|
||||||
|
!response.candidates[0].content
|
||||||
|
) {
|
||||||
|
throw new Error("No response received from Gemini AI");
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = response.candidates[0].content;
|
const content = response.candidates[0].content;
|
||||||
|
|
@ -217,8 +224,8 @@ export class ImageGenService {
|
||||||
// Extract image data and description from response
|
// Extract image data and description from response
|
||||||
for (const part of content.parts || []) {
|
for (const part of content.parts || []) {
|
||||||
if (part.inlineData) {
|
if (part.inlineData) {
|
||||||
const buffer = Buffer.from(part.inlineData.data || '', 'base64');
|
const buffer = Buffer.from(part.inlineData.data || "", "base64");
|
||||||
const mimeType = part.inlineData.mimeType || 'image/png';
|
const mimeType = part.inlineData.mimeType || "image/png";
|
||||||
imageData = { buffer, mimeType };
|
imageData = { buffer, mimeType };
|
||||||
} else if (part.text) {
|
} else if (part.text) {
|
||||||
generatedDescription = part.text;
|
generatedDescription = part.text;
|
||||||
|
|
@ -226,10 +233,10 @@ export class ImageGenService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!imageData) {
|
if (!imageData) {
|
||||||
throw new Error('No image data received from Gemini AI');
|
throw new Error("No image data received from Gemini AI");
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileExtension = mime.getExtension(imageData.mimeType) || 'png';
|
const fileExtension = mime.getExtension(imageData.mimeType) || "png";
|
||||||
|
|
||||||
const generatedData: GeneratedImageData = {
|
const generatedData: GeneratedImageData = {
|
||||||
buffer: imageData.buffer,
|
buffer: imageData.buffer,
|
||||||
|
|
@ -243,18 +250,11 @@ export class ImageGenService {
|
||||||
geminiParams,
|
geminiParams,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Enhanced error detection with network diagnostics
|
// Re-throw with clear error message
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
// Classify the error and check for network issues (only on failure)
|
throw new Error(`Gemini AI generation failed: ${error.message}`);
|
||||||
const errorAnalysis = await NetworkErrorDetector.classifyError(error, 'Gemini API');
|
|
||||||
|
|
||||||
// Log the detailed error for debugging
|
|
||||||
console.error(`[ImageGenService] ${NetworkErrorDetector.formatErrorForLogging(errorAnalysis)}`);
|
|
||||||
|
|
||||||
// Throw user-friendly error message
|
|
||||||
throw new Error(errorAnalysis.userMessage);
|
|
||||||
}
|
}
|
||||||
throw new Error('Gemini AI generation failed: Unknown error');
|
throw new Error("Gemini AI generation failed: Unknown error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -263,10 +263,10 @@ export class ImageGenService {
|
||||||
error?: string;
|
error?: string;
|
||||||
} {
|
} {
|
||||||
if (files.length > 3) {
|
if (files.length > 3) {
|
||||||
return { valid: false, error: 'Maximum 3 reference images allowed' };
|
return { valid: false, error: "Maximum 3 reference images allowed" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
|
const allowedTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp"];
|
||||||
const maxSize = 5 * 1024 * 1024; // 5MB
|
const maxSize = 5 * 1024 * 1024; // 5MB
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
|
|
@ -288,7 +288,9 @@ export class ImageGenService {
|
||||||
return { valid: true };
|
return { valid: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
static convertFilesToReferenceImages(files: Express.Multer.File[]): ReferenceImage[] {
|
static convertFilesToReferenceImages(
|
||||||
|
files: Express.Multer.File[],
|
||||||
|
): ReferenceImage[] {
|
||||||
return files.map((file) => ({
|
return files.map((file) => ({
|
||||||
buffer: file.buffer,
|
buffer: file.buffer,
|
||||||
mimetype: file.mimetype,
|
mimetype: file.mimetype,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Client as MinioClient } from 'minio';
|
import { Client as MinioClient } from "minio";
|
||||||
import { StorageService, FileMetadata, UploadResult } from './StorageService';
|
import { StorageService, FileMetadata, UploadResult } from "./StorageService";
|
||||||
|
|
||||||
export class MinioStorageService implements StorageService {
|
export class MinioStorageService implements StorageService {
|
||||||
private client: MinioClient;
|
private client: MinioClient;
|
||||||
|
|
@ -11,12 +11,12 @@ export class MinioStorageService implements StorageService {
|
||||||
accessKey: string,
|
accessKey: string,
|
||||||
secretKey: string,
|
secretKey: string,
|
||||||
useSSL: boolean = false,
|
useSSL: boolean = false,
|
||||||
bucketName: string = 'banatie',
|
bucketName: string = "banatie",
|
||||||
publicUrl?: string,
|
publicUrl?: string,
|
||||||
) {
|
) {
|
||||||
// Parse endpoint to separate hostname and port
|
// Parse endpoint to separate hostname and port
|
||||||
const cleanEndpoint = endpoint.replace(/^https?:\/\//, '');
|
const cleanEndpoint = endpoint.replace(/^https?:\/\//, "");
|
||||||
const [hostname, portStr] = cleanEndpoint.split(':');
|
const [hostname, portStr] = cleanEndpoint.split(":");
|
||||||
const port = portStr ? parseInt(portStr, 10) : useSSL ? 443 : 9000;
|
const port = portStr ? parseInt(portStr, 10) : useSSL ? 443 : 9000;
|
||||||
|
|
||||||
if (!hostname) {
|
if (!hostname) {
|
||||||
|
|
@ -31,13 +31,13 @@ export class MinioStorageService implements StorageService {
|
||||||
secretKey,
|
secretKey,
|
||||||
});
|
});
|
||||||
this.bucketName = bucketName;
|
this.bucketName = bucketName;
|
||||||
this.publicUrl = publicUrl || `${useSSL ? 'https' : 'http'}://${endpoint}`;
|
this.publicUrl = publicUrl || `${useSSL ? "https" : "http"}://${endpoint}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getFilePath(
|
private getFilePath(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
category: 'uploads' | 'generated' | 'references',
|
category: "uploads" | "generated" | "references",
|
||||||
filename: string,
|
filename: string,
|
||||||
): string {
|
): string {
|
||||||
// Simplified path without date folder for now
|
// Simplified path without date folder for now
|
||||||
|
|
@ -50,9 +50,11 @@ export class MinioStorageService implements StorageService {
|
||||||
|
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const random = Math.random().toString(36).substring(2, 8);
|
const random = Math.random().toString(36).substring(2, 8);
|
||||||
const ext = sanitized.includes('.') ? sanitized.substring(sanitized.lastIndexOf('.')) : '';
|
const ext = sanitized.includes(".")
|
||||||
const name = sanitized.includes('.')
|
? sanitized.substring(sanitized.lastIndexOf("."))
|
||||||
? sanitized.substring(0, sanitized.lastIndexOf('.'))
|
: "";
|
||||||
|
const name = sanitized.includes(".")
|
||||||
|
? sanitized.substring(0, sanitized.lastIndexOf("."))
|
||||||
: sanitized;
|
: sanitized;
|
||||||
|
|
||||||
return `${name}-${timestamp}-${random}${ext}`;
|
return `${name}-${timestamp}-${random}${ext}`;
|
||||||
|
|
@ -61,9 +63,9 @@ export class MinioStorageService implements StorageService {
|
||||||
private sanitizeFilename(filename: string): string {
|
private sanitizeFilename(filename: string): string {
|
||||||
// Remove dangerous characters and path traversal attempts
|
// Remove dangerous characters and path traversal attempts
|
||||||
return filename
|
return filename
|
||||||
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '') // Remove dangerous chars
|
.replace(/[<>:"/\\|?*\x00-\x1f]/g, "") // Remove dangerous chars
|
||||||
.replace(/\.\./g, '') // Remove path traversal
|
.replace(/\.\./g, "") // Remove path traversal
|
||||||
.replace(/^\.+/, '') // Remove leading dots
|
.replace(/^\.+/, "") // Remove leading dots
|
||||||
.trim()
|
.trim()
|
||||||
.substring(0, 255); // Limit length
|
.substring(0, 255); // Limit length
|
||||||
}
|
}
|
||||||
|
|
@ -77,42 +79,54 @@ export class MinioStorageService implements StorageService {
|
||||||
// Validate orgId
|
// Validate orgId
|
||||||
if (!orgId || !/^[a-zA-Z0-9_-]+$/.test(orgId) || orgId.length > 50) {
|
if (!orgId || !/^[a-zA-Z0-9_-]+$/.test(orgId) || orgId.length > 50) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Invalid organization ID: must be alphanumeric with dashes/underscores, max 50 chars',
|
"Invalid organization ID: must be alphanumeric with dashes/underscores, max 50 chars",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate projectId
|
// Validate projectId
|
||||||
if (!projectId || !/^[a-zA-Z0-9_-]+$/.test(projectId) || projectId.length > 50) {
|
if (
|
||||||
|
!projectId ||
|
||||||
|
!/^[a-zA-Z0-9_-]+$/.test(projectId) ||
|
||||||
|
projectId.length > 50
|
||||||
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Invalid project ID: must be alphanumeric with dashes/underscores, max 50 chars',
|
"Invalid project ID: must be alphanumeric with dashes/underscores, max 50 chars",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate category
|
// Validate category
|
||||||
if (!['uploads', 'generated', 'references'].includes(category)) {
|
if (!["uploads", "generated", "references"].includes(category)) {
|
||||||
throw new Error('Invalid category: must be uploads, generated, or references');
|
throw new Error(
|
||||||
|
"Invalid category: must be uploads, generated, or references",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate filename
|
// Validate filename
|
||||||
if (!filename || filename.length === 0 || filename.length > 255) {
|
if (!filename || filename.length === 0 || filename.length > 255) {
|
||||||
throw new Error('Invalid filename: must be 1-255 characters');
|
throw new Error("Invalid filename: must be 1-255 characters");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for path traversal and dangerous patterns
|
// Check for path traversal and dangerous patterns
|
||||||
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
if (
|
||||||
throw new Error('Invalid characters in filename: path traversal not allowed');
|
filename.includes("..") ||
|
||||||
|
filename.includes("/") ||
|
||||||
|
filename.includes("\\")
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid characters in filename: path traversal not allowed",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent null bytes and control characters
|
// Prevent null bytes and control characters
|
||||||
if (/[\x00-\x1f]/.test(filename)) {
|
if (/[\x00-\x1f]/.test(filename)) {
|
||||||
throw new Error('Invalid filename: control characters not allowed');
|
throw new Error("Invalid filename: control characters not allowed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createBucket(): Promise<void> {
|
async createBucket(): Promise<void> {
|
||||||
const exists = await this.client.bucketExists(this.bucketName);
|
const exists = await this.client.bucketExists(this.bucketName);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
await this.client.makeBucket(this.bucketName, 'us-east-1');
|
await this.client.makeBucket(this.bucketName, "us-east-1");
|
||||||
console.log(`Created bucket: ${this.bucketName}`);
|
console.log(`Created bucket: ${this.bucketName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,7 +141,7 @@ export class MinioStorageService implements StorageService {
|
||||||
async uploadFile(
|
async uploadFile(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
category: 'uploads' | 'generated' | 'references',
|
category: "uploads" | "generated" | "references",
|
||||||
filename: string,
|
filename: string,
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
contentType: string,
|
contentType: string,
|
||||||
|
|
@ -136,11 +150,11 @@ export class MinioStorageService implements StorageService {
|
||||||
this.validateFilePath(orgId, projectId, category, filename);
|
this.validateFilePath(orgId, projectId, category, filename);
|
||||||
|
|
||||||
if (!buffer || buffer.length === 0) {
|
if (!buffer || buffer.length === 0) {
|
||||||
throw new Error('Buffer cannot be empty');
|
throw new Error("Buffer cannot be empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!contentType || contentType.trim().length === 0) {
|
if (!contentType || contentType.trim().length === 0) {
|
||||||
throw new Error('Content type is required');
|
throw new Error("Content type is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure bucket exists
|
// Ensure bucket exists
|
||||||
|
|
@ -148,15 +162,20 @@ export class MinioStorageService implements StorageService {
|
||||||
|
|
||||||
// Generate unique filename to avoid conflicts
|
// Generate unique filename to avoid conflicts
|
||||||
const uniqueFilename = this.generateUniqueFilename(filename);
|
const uniqueFilename = this.generateUniqueFilename(filename);
|
||||||
const filePath = this.getFilePath(orgId, projectId, category, uniqueFilename);
|
const filePath = this.getFilePath(
|
||||||
|
orgId,
|
||||||
|
projectId,
|
||||||
|
category,
|
||||||
|
uniqueFilename,
|
||||||
|
);
|
||||||
|
|
||||||
const metadata = {
|
const metadata = {
|
||||||
'Content-Type': contentType,
|
"Content-Type": contentType,
|
||||||
'X-Amz-Meta-Original-Name': filename,
|
"X-Amz-Meta-Original-Name": filename,
|
||||||
'X-Amz-Meta-Category': category,
|
"X-Amz-Meta-Category": category,
|
||||||
'X-Amz-Meta-Project': projectId,
|
"X-Amz-Meta-Project": projectId,
|
||||||
'X-Amz-Meta-Organization': orgId,
|
"X-Amz-Meta-Organization": orgId,
|
||||||
'X-Amz-Meta-Upload-Time': new Date().toISOString(),
|
"X-Amz-Meta-Upload-Time": new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`Uploading file to: ${this.bucketName}/${filePath}`);
|
console.log(`Uploading file to: ${this.bucketName}/${filePath}`);
|
||||||
|
|
@ -186,7 +205,7 @@ export class MinioStorageService implements StorageService {
|
||||||
async downloadFile(
|
async downloadFile(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
category: 'uploads' | 'generated' | 'references',
|
category: "uploads" | "generated" | "references",
|
||||||
filename: string,
|
filename: string,
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
this.validateFilePath(orgId, projectId, category, filename);
|
this.validateFilePath(orgId, projectId, category, filename);
|
||||||
|
|
@ -196,18 +215,18 @@ export class MinioStorageService implements StorageService {
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
stream.on('data', (chunk) => chunks.push(chunk));
|
stream.on("data", (chunk) => chunks.push(chunk));
|
||||||
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
||||||
stream.on('error', reject);
|
stream.on("error", reject);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async streamFile(
|
async streamFile(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
category: 'uploads' | 'generated' | 'references',
|
category: "uploads" | "generated" | "references",
|
||||||
filename: string,
|
filename: string,
|
||||||
): Promise<import('stream').Readable> {
|
): Promise<import("stream").Readable> {
|
||||||
this.validateFilePath(orgId, projectId, category, filename);
|
this.validateFilePath(orgId, projectId, category, filename);
|
||||||
const filePath = this.getFilePath(orgId, projectId, category, filename);
|
const filePath = this.getFilePath(orgId, projectId, category, filename);
|
||||||
|
|
||||||
|
|
@ -218,7 +237,7 @@ export class MinioStorageService implements StorageService {
|
||||||
async deleteFile(
|
async deleteFile(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
category: 'uploads' | 'generated' | 'references',
|
category: "uploads" | "generated" | "references",
|
||||||
filename: string,
|
filename: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.validateFilePath(orgId, projectId, category, filename);
|
this.validateFilePath(orgId, projectId, category, filename);
|
||||||
|
|
@ -229,19 +248,19 @@ export class MinioStorageService implements StorageService {
|
||||||
getPublicUrl(
|
getPublicUrl(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
category: 'uploads' | 'generated' | 'references',
|
category: "uploads" | "generated" | "references",
|
||||||
filename: string,
|
filename: string,
|
||||||
): string {
|
): string {
|
||||||
this.validateFilePath(orgId, projectId, category, filename);
|
this.validateFilePath(orgId, projectId, category, filename);
|
||||||
// Production-ready: Return API URL for presigned URL access
|
// Production-ready: Return API URL for presigned URL access
|
||||||
const apiBaseUrl = process.env['API_BASE_URL'] || 'http://localhost:3000';
|
const apiBaseUrl = process.env["API_BASE_URL"] || "http://localhost:3000";
|
||||||
return `${apiBaseUrl}/api/images/${orgId}/${projectId}/${category}/${filename}`;
|
return `${apiBaseUrl}/api/images/${orgId}/${projectId}/${category}/${filename}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPresignedUploadUrl(
|
async getPresignedUploadUrl(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
category: 'uploads' | 'generated' | 'references',
|
category: "uploads" | "generated" | "references",
|
||||||
filename: string,
|
filename: string,
|
||||||
expirySeconds: number,
|
expirySeconds: number,
|
||||||
contentType: string,
|
contentType: string,
|
||||||
|
|
@ -249,17 +268,21 @@ export class MinioStorageService implements StorageService {
|
||||||
this.validateFilePath(orgId, projectId, category, filename);
|
this.validateFilePath(orgId, projectId, category, filename);
|
||||||
|
|
||||||
if (!contentType || contentType.trim().length === 0) {
|
if (!contentType || contentType.trim().length === 0) {
|
||||||
throw new Error('Content type is required for presigned upload URL');
|
throw new Error("Content type is required for presigned upload URL");
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = this.getFilePath(orgId, projectId, category, filename);
|
const filePath = this.getFilePath(orgId, projectId, category, filename);
|
||||||
return await this.client.presignedPutObject(this.bucketName, filePath, expirySeconds);
|
return await this.client.presignedPutObject(
|
||||||
|
this.bucketName,
|
||||||
|
filePath,
|
||||||
|
expirySeconds,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPresignedDownloadUrl(
|
async getPresignedDownloadUrl(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
category: 'uploads' | 'generated' | 'references',
|
category: "uploads" | "generated" | "references",
|
||||||
filename: string,
|
filename: string,
|
||||||
expirySeconds: number = 86400, // 24 hours default
|
expirySeconds: number = 86400, // 24 hours default
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
|
@ -273,10 +296,14 @@ export class MinioStorageService implements StorageService {
|
||||||
|
|
||||||
// Replace internal Docker hostname with public URL if configured
|
// Replace internal Docker hostname with public URL if configured
|
||||||
if (this.publicUrl) {
|
if (this.publicUrl) {
|
||||||
const clientEndpoint = this.client.host + (this.client.port ? `:${this.client.port}` : '');
|
const clientEndpoint =
|
||||||
const publicEndpoint = this.publicUrl.replace(/^https?:\/\//, '');
|
this.client.host + (this.client.port ? `:${this.client.port}` : "");
|
||||||
|
const publicEndpoint = this.publicUrl.replace(/^https?:\/\//, "");
|
||||||
|
|
||||||
return presignedUrl.replace(`${this.client.protocol}//${clientEndpoint}`, this.publicUrl);
|
return presignedUrl.replace(
|
||||||
|
`${this.client.protocol}//${clientEndpoint}`,
|
||||||
|
this.publicUrl,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return presignedUrl;
|
return presignedUrl;
|
||||||
|
|
@ -285,24 +312,32 @@ export class MinioStorageService implements StorageService {
|
||||||
async listProjectFiles(
|
async listProjectFiles(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
category?: 'uploads' | 'generated' | 'references',
|
category?: "uploads" | "generated" | "references",
|
||||||
): Promise<FileMetadata[]> {
|
): Promise<FileMetadata[]> {
|
||||||
const prefix = category ? `${orgId}/${projectId}/${category}/` : `${orgId}/${projectId}/`;
|
const prefix = category
|
||||||
|
? `${orgId}/${projectId}/${category}/`
|
||||||
|
: `${orgId}/${projectId}/`;
|
||||||
|
|
||||||
const files: FileMetadata[] = [];
|
const files: FileMetadata[] = [];
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const stream = this.client.listObjects(this.bucketName, prefix, true);
|
const stream = this.client.listObjects(this.bucketName, prefix, true);
|
||||||
|
|
||||||
stream.on('data', async (obj) => {
|
stream.on("data", async (obj) => {
|
||||||
try {
|
try {
|
||||||
if (!obj.name) return;
|
if (!obj.name) return;
|
||||||
|
|
||||||
const metadata = await this.client.statObject(this.bucketName, obj.name);
|
const metadata = await this.client.statObject(
|
||||||
|
this.bucketName,
|
||||||
|
obj.name,
|
||||||
|
);
|
||||||
|
|
||||||
const pathParts = obj.name.split('/');
|
const pathParts = obj.name.split("/");
|
||||||
const filename = pathParts[pathParts.length - 1];
|
const filename = pathParts[pathParts.length - 1];
|
||||||
const categoryFromPath = pathParts[2] as 'uploads' | 'generated' | 'references';
|
const categoryFromPath = pathParts[2] as
|
||||||
|
| "uploads"
|
||||||
|
| "generated"
|
||||||
|
| "references";
|
||||||
|
|
||||||
if (!filename || !categoryFromPath) {
|
if (!filename || !categoryFromPath) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -311,23 +346,29 @@ export class MinioStorageService implements StorageService {
|
||||||
files.push({
|
files.push({
|
||||||
key: `${this.bucketName}/${obj.name}`,
|
key: `${this.bucketName}/${obj.name}`,
|
||||||
filename,
|
filename,
|
||||||
contentType: metadata.metaData?.['content-type'] || 'application/octet-stream',
|
contentType:
|
||||||
|
metadata.metaData?.["content-type"] || "application/octet-stream",
|
||||||
size: obj.size || 0,
|
size: obj.size || 0,
|
||||||
url: this.getPublicUrl(orgId, projectId, categoryFromPath, filename),
|
url: this.getPublicUrl(
|
||||||
|
orgId,
|
||||||
|
projectId,
|
||||||
|
categoryFromPath,
|
||||||
|
filename,
|
||||||
|
),
|
||||||
createdAt: obj.lastModified || new Date(),
|
createdAt: obj.lastModified || new Date(),
|
||||||
});
|
});
|
||||||
} catch (error) {}
|
} catch (error) {}
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.on('end', () => resolve(files));
|
stream.on("end", () => resolve(files));
|
||||||
stream.on('error', reject);
|
stream.on("error", reject);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
parseKey(key: string): {
|
parseKey(key: string): {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
category: 'uploads' | 'generated' | 'references';
|
category: "uploads" | "generated" | "references";
|
||||||
filename: string;
|
filename: string;
|
||||||
} | null {
|
} | null {
|
||||||
try {
|
try {
|
||||||
|
|
@ -348,7 +389,7 @@ export class MinioStorageService implements StorageService {
|
||||||
return {
|
return {
|
||||||
orgId,
|
orgId,
|
||||||
projectId,
|
projectId,
|
||||||
category: category as 'uploads' | 'generated' | 'references',
|
category: category as "uploads" | "generated" | "references",
|
||||||
filename,
|
filename,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -359,7 +400,7 @@ export class MinioStorageService implements StorageService {
|
||||||
async fileExists(
|
async fileExists(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
category: 'uploads' | 'generated' | 'references',
|
category: "uploads" | "generated" | "references",
|
||||||
filename: string,
|
filename: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -375,10 +416,10 @@ export class MinioStorageService implements StorageService {
|
||||||
async listFiles(
|
async listFiles(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
category: 'uploads' | 'generated' | 'references',
|
category: "uploads" | "generated" | "references",
|
||||||
prefix?: string,
|
prefix?: string,
|
||||||
): Promise<FileMetadata[]> {
|
): Promise<FileMetadata[]> {
|
||||||
this.validateFilePath(orgId, projectId, category, 'dummy.txt');
|
this.validateFilePath(orgId, projectId, category, "dummy.txt");
|
||||||
|
|
||||||
const basePath = `${orgId}/${projectId}/${category}/`;
|
const basePath = `${orgId}/${projectId}/${category}/`;
|
||||||
const searchPrefix = prefix ? `${basePath}${prefix}` : basePath;
|
const searchPrefix = prefix ? `${basePath}${prefix}` : basePath;
|
||||||
|
|
@ -386,23 +427,31 @@ export class MinioStorageService implements StorageService {
|
||||||
const files: FileMetadata[] = [];
|
const files: FileMetadata[] = [];
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const stream = this.client.listObjects(this.bucketName, searchPrefix, true);
|
const stream = this.client.listObjects(
|
||||||
|
this.bucketName,
|
||||||
|
searchPrefix,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
stream.on('data', async (obj) => {
|
stream.on("data", async (obj) => {
|
||||||
if (!obj.name || !obj.size) return;
|
if (!obj.name || !obj.size) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pathParts = obj.name.split('/');
|
const pathParts = obj.name.split("/");
|
||||||
const filename = pathParts[pathParts.length - 1];
|
const filename = pathParts[pathParts.length - 1];
|
||||||
|
|
||||||
if (!filename) return;
|
if (!filename) return;
|
||||||
|
|
||||||
const metadata = await this.client.statObject(this.bucketName, obj.name);
|
const metadata = await this.client.statObject(
|
||||||
|
this.bucketName,
|
||||||
|
obj.name,
|
||||||
|
);
|
||||||
|
|
||||||
files.push({
|
files.push({
|
||||||
filename,
|
filename,
|
||||||
size: obj.size,
|
size: obj.size,
|
||||||
contentType: metadata.metaData?.['content-type'] || 'application/octet-stream',
|
contentType:
|
||||||
|
metadata.metaData?.["content-type"] || "application/octet-stream",
|
||||||
lastModified: obj.lastModified || new Date(),
|
lastModified: obj.lastModified || new Date(),
|
||||||
etag: metadata.etag,
|
etag: metadata.etag,
|
||||||
path: obj.name,
|
path: obj.name,
|
||||||
|
|
@ -410,8 +459,8 @@ export class MinioStorageService implements StorageService {
|
||||||
} catch (error) {}
|
} catch (error) {}
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.on('end', () => resolve(files));
|
stream.on("end", () => resolve(files));
|
||||||
stream.on('error', reject);
|
stream.on("error", reject);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { StorageService } from './StorageService';
|
import { StorageService } from "./StorageService";
|
||||||
import { MinioStorageService } from './MinioStorageService';
|
import { MinioStorageService } from "./MinioStorageService";
|
||||||
|
|
||||||
export class StorageFactory {
|
export class StorageFactory {
|
||||||
private static instance: StorageService | null = null;
|
private static instance: StorageService | null = null;
|
||||||
|
|
@ -30,7 +30,9 @@ export class StorageFactory {
|
||||||
try {
|
try {
|
||||||
this.instance = this.createStorageService();
|
this.instance = this.createStorageService();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error('Storage service unavailable. Please check MinIO configuration.');
|
throw new Error(
|
||||||
|
"Storage service unavailable. Please check MinIO configuration.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return this.instance;
|
return this.instance;
|
||||||
|
|
@ -51,7 +53,7 @@ export class StorageFactory {
|
||||||
if (attempt === maxRetries) {
|
if (attempt === maxRetries) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to initialize storage service after ${maxRetries} attempts. ` +
|
`Failed to initialize storage service after ${maxRetries} attempts. ` +
|
||||||
`Last error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
`Last error: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,7 +62,7 @@ export class StorageFactory {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('Unexpected error in storage service creation');
|
throw new Error("Unexpected error in storage service creation");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static sleep(ms: number): Promise<void> {
|
private static sleep(ms: number): Promise<void> {
|
||||||
|
|
@ -68,21 +70,21 @@ export class StorageFactory {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static createStorageService(): StorageService {
|
private static createStorageService(): StorageService {
|
||||||
const storageType = process.env['STORAGE_TYPE'] || 'minio';
|
const storageType = process.env["STORAGE_TYPE"] || "minio";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (storageType.toLowerCase()) {
|
switch (storageType.toLowerCase()) {
|
||||||
case 'minio': {
|
case "minio": {
|
||||||
const endpoint = process.env['MINIO_ENDPOINT'];
|
const endpoint = process.env["MINIO_ENDPOINT"];
|
||||||
const accessKey = process.env['MINIO_ACCESS_KEY'];
|
const accessKey = process.env["MINIO_ACCESS_KEY"];
|
||||||
const secretKey = process.env['MINIO_SECRET_KEY'];
|
const secretKey = process.env["MINIO_SECRET_KEY"];
|
||||||
const useSSL = process.env['MINIO_USE_SSL'] === 'true';
|
const useSSL = process.env["MINIO_USE_SSL"] === "true";
|
||||||
const bucketName = process.env['MINIO_BUCKET_NAME'] || 'banatie';
|
const bucketName = process.env["MINIO_BUCKET_NAME"] || "banatie";
|
||||||
const publicUrl = process.env['MINIO_PUBLIC_URL'];
|
const publicUrl = process.env["MINIO_PUBLIC_URL"];
|
||||||
|
|
||||||
if (!endpoint || !accessKey || !secretKey) {
|
if (!endpoint || !accessKey || !secretKey) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'MinIO configuration missing. Required: MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY',
|
"MinIO configuration missing. Required: MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Readable } from 'stream';
|
import { Readable } from "stream";
|
||||||
|
|
||||||
export interface FileMetadata {
|
export interface FileMetadata {
|
||||||
filename: string;
|
filename: string;
|
||||||
|
|
@ -42,7 +42,7 @@ export interface StorageService {
|
||||||
uploadFile(
|
uploadFile(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
category: 'uploads' | 'generated' | 'references',
|
category: "uploads" | "generated" | "references",
|
||||||
filename: string,
|
filename: string,
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
contentType: string,
|
contentType: string,
|
||||||
|
|
@ -58,7 +58,7 @@ export interface StorageService {
|
||||||
downloadFile(
|
downloadFile(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
category: 'uploads' | 'generated' | 'references',
|
category: "uploads" | "generated" | "references",
|
||||||
filename: string,
|
filename: string,
|
||||||
): Promise<Buffer>;
|
): Promise<Buffer>;
|
||||||
|
|
||||||
|
|
@ -72,7 +72,7 @@ export interface StorageService {
|
||||||
streamFile(
|
streamFile(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
category: 'uploads' | 'generated' | 'references',
|
category: "uploads" | "generated" | "references",
|
||||||
filename: string,
|
filename: string,
|
||||||
): Promise<Readable>;
|
): Promise<Readable>;
|
||||||
|
|
||||||
|
|
@ -87,7 +87,7 @@ export interface StorageService {
|
||||||
getPresignedDownloadUrl(
|
getPresignedDownloadUrl(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
category: 'uploads' | 'generated' | 'references',
|
category: "uploads" | "generated" | "references",
|
||||||
filename: string,
|
filename: string,
|
||||||
expirySeconds: number,
|
expirySeconds: number,
|
||||||
): Promise<string>;
|
): Promise<string>;
|
||||||
|
|
@ -104,7 +104,7 @@ export interface StorageService {
|
||||||
getPresignedUploadUrl(
|
getPresignedUploadUrl(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
category: 'uploads' | 'generated' | 'references',
|
category: "uploads" | "generated" | "references",
|
||||||
filename: string,
|
filename: string,
|
||||||
expirySeconds: number,
|
expirySeconds: number,
|
||||||
contentType: string,
|
contentType: string,
|
||||||
|
|
@ -120,7 +120,7 @@ export interface StorageService {
|
||||||
listFiles(
|
listFiles(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
category: 'uploads' | 'generated' | 'references',
|
category: "uploads" | "generated" | "references",
|
||||||
prefix?: string,
|
prefix?: string,
|
||||||
): Promise<FileMetadata[]>;
|
): Promise<FileMetadata[]>;
|
||||||
|
|
||||||
|
|
@ -134,7 +134,7 @@ export interface StorageService {
|
||||||
deleteFile(
|
deleteFile(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
category: 'uploads' | 'generated' | 'references',
|
category: "uploads" | "generated" | "references",
|
||||||
filename: string,
|
filename: string,
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
|
|
||||||
|
|
@ -148,7 +148,7 @@ export interface StorageService {
|
||||||
fileExists(
|
fileExists(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
category: 'uploads' | 'generated' | 'references',
|
category: "uploads" | "generated" | "references",
|
||||||
filename: string,
|
filename: string,
|
||||||
): Promise<boolean>;
|
): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
|
import { writeFileSync, readFileSync, existsSync, mkdirSync } from "fs";
|
||||||
import { dirname } from 'path';
|
import { dirname } from "path";
|
||||||
|
|
||||||
export interface TTILogEntry {
|
export interface TTILogEntry {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
|
|
@ -24,7 +24,7 @@ export class TTILogger {
|
||||||
private isEnabled: boolean = false;
|
private isEnabled: boolean = false;
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
const ttiLogPath = process.env['TTI_LOG'];
|
const ttiLogPath = process.env["TTI_LOG"];
|
||||||
|
|
||||||
if (ttiLogPath) {
|
if (ttiLogPath) {
|
||||||
this.logFilePath = ttiLogPath;
|
this.logFilePath = ttiLogPath;
|
||||||
|
|
@ -51,8 +51,8 @@ export class TTILogger {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset/clear the log file on service start
|
// Reset/clear the log file on service start
|
||||||
writeFileSync(this.logFilePath, '# Text-to-Image Generation Log\n\n', {
|
writeFileSync(this.logFilePath, "# Text-to-Image Generation Log\n\n", {
|
||||||
encoding: 'utf-8',
|
encoding: "utf-8",
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[TTILogger] Log file initialized: ${this.logFilePath}`);
|
console.log(`[TTILogger] Log file initialized: ${this.logFilePath}`);
|
||||||
|
|
@ -72,15 +72,19 @@ export class TTILogger {
|
||||||
|
|
||||||
// Read existing content
|
// Read existing content
|
||||||
const existingContent = existsSync(this.logFilePath)
|
const existingContent = existsSync(this.logFilePath)
|
||||||
? readFileSync(this.logFilePath, 'utf-8')
|
? readFileSync(this.logFilePath, "utf-8")
|
||||||
: '# Text-to-Image Generation Log\n\n';
|
: "# Text-to-Image Generation Log\n\n";
|
||||||
|
|
||||||
// Insert new entry AFTER header but BEFORE old entries
|
// Insert new entry AFTER header but BEFORE old entries
|
||||||
const headerEnd = existingContent.indexOf('\n\n') + 2;
|
const headerEnd = existingContent.indexOf("\n\n") + 2;
|
||||||
const header = existingContent.slice(0, headerEnd);
|
const header = existingContent.slice(0, headerEnd);
|
||||||
const oldEntries = existingContent.slice(headerEnd);
|
const oldEntries = existingContent.slice(headerEnd);
|
||||||
|
|
||||||
writeFileSync(this.logFilePath, header + newLogEntry + oldEntries, 'utf-8');
|
writeFileSync(
|
||||||
|
this.logFilePath,
|
||||||
|
header + newLogEntry + oldEntries,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[TTILogger] Failed to write log entry:`, error);
|
console.error(`[TTILogger] Failed to write log entry:`, error);
|
||||||
}
|
}
|
||||||
|
|
@ -91,7 +95,7 @@ export class TTILogger {
|
||||||
|
|
||||||
// Format date from ISO timestamp
|
// Format date from ISO timestamp
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
const formattedDate = date.toISOString().replace('T', ' ').slice(0, 19);
|
const formattedDate = date.toISOString().replace("T", " ").slice(0, 19);
|
||||||
|
|
||||||
let logText = `## ${formattedDate}\n`;
|
let logText = `## ${formattedDate}\n`;
|
||||||
logText += `${orgId}/${projectId}\n\n`;
|
logText += `${orgId}/${projectId}\n\n`;
|
||||||
|
|
@ -99,16 +103,16 @@ export class TTILogger {
|
||||||
|
|
||||||
// Add tags if present
|
// Add tags if present
|
||||||
if (meta?.tags && meta.tags.length > 0) {
|
if (meta?.tags && meta.tags.length > 0) {
|
||||||
logText += `**Tags:** ${meta.tags.join(', ')}\n\n`;
|
logText += `**Tags:** ${meta.tags.join(", ")}\n\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (referenceImages && referenceImages.length > 0) {
|
if (referenceImages && referenceImages.length > 0) {
|
||||||
logText += `**Reference Images:** ${referenceImages.length} image${referenceImages.length > 1 ? 's' : ''}\n`;
|
logText += `**Reference Images:** ${referenceImages.length} image${referenceImages.length > 1 ? "s" : ""}\n`;
|
||||||
for (const img of referenceImages) {
|
for (const img of referenceImages) {
|
||||||
const sizeMB = (img.size / (1024 * 1024)).toFixed(2);
|
const sizeMB = (img.size / (1024 * 1024)).toFixed(2);
|
||||||
logText += `- ${img.originalname} (${img.mimetype}, ${sizeMB} MB)\n`;
|
logText += `- ${img.originalname} (${img.mimetype}, ${sizeMB} MB)\n`;
|
||||||
}
|
}
|
||||||
logText += '\n';
|
logText += "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
logText += `**Model:** ${model}\n`;
|
logText += `**Model:** ${model}\n`;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
|
import { writeFileSync, readFileSync, existsSync, mkdirSync } from "fs";
|
||||||
import { dirname } from 'path';
|
import { dirname } from "path";
|
||||||
import { EnhancementLogEntry } from './types';
|
import { EnhancementLogEntry } from "./types";
|
||||||
|
|
||||||
export class EnhancementLogger {
|
export class EnhancementLogger {
|
||||||
private static instance: EnhancementLogger | null = null;
|
private static instance: EnhancementLogger | null = null;
|
||||||
|
|
@ -8,7 +8,7 @@ export class EnhancementLogger {
|
||||||
private isEnabled: boolean = false;
|
private isEnabled: boolean = false;
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
const enhLogPath = process.env['ENH_LOG'];
|
const enhLogPath = process.env["ENH_LOG"];
|
||||||
|
|
||||||
if (enhLogPath) {
|
if (enhLogPath) {
|
||||||
this.logFilePath = enhLogPath;
|
this.logFilePath = enhLogPath;
|
||||||
|
|
@ -35,13 +35,18 @@ export class EnhancementLogger {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset/clear the log file on service start
|
// Reset/clear the log file on service start
|
||||||
writeFileSync(this.logFilePath, '# Prompt Enhancement Log\n\n', {
|
writeFileSync(this.logFilePath, "# Prompt Enhancement Log\n\n", {
|
||||||
encoding: 'utf-8',
|
encoding: "utf-8",
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[EnhancementLogger] Log file initialized: ${this.logFilePath}`);
|
console.log(
|
||||||
|
`[EnhancementLogger] Log file initialized: ${this.logFilePath}`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[EnhancementLogger] Failed to initialize log file:`, error);
|
console.error(
|
||||||
|
`[EnhancementLogger] Failed to initialize log file:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
this.isEnabled = false;
|
this.isEnabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -56,15 +61,19 @@ export class EnhancementLogger {
|
||||||
|
|
||||||
// Read existing content
|
// Read existing content
|
||||||
const existingContent = existsSync(this.logFilePath)
|
const existingContent = existsSync(this.logFilePath)
|
||||||
? readFileSync(this.logFilePath, 'utf-8')
|
? readFileSync(this.logFilePath, "utf-8")
|
||||||
: '# Prompt Enhancement Log\n\n';
|
: "# Prompt Enhancement Log\n\n";
|
||||||
|
|
||||||
// Insert new entry AFTER header but BEFORE old entries
|
// Insert new entry AFTER header but BEFORE old entries
|
||||||
const headerEnd = existingContent.indexOf('\n\n') + 2;
|
const headerEnd = existingContent.indexOf("\n\n") + 2;
|
||||||
const header = existingContent.slice(0, headerEnd);
|
const header = existingContent.slice(0, headerEnd);
|
||||||
const oldEntries = existingContent.slice(headerEnd);
|
const oldEntries = existingContent.slice(headerEnd);
|
||||||
|
|
||||||
writeFileSync(this.logFilePath, header + newLogEntry + oldEntries, 'utf-8');
|
writeFileSync(
|
||||||
|
this.logFilePath,
|
||||||
|
header + newLogEntry + oldEntries,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[EnhancementLogger] Failed to write log entry:`, error);
|
console.error(`[EnhancementLogger] Failed to write log entry:`, error);
|
||||||
}
|
}
|
||||||
|
|
@ -86,7 +95,7 @@ export class EnhancementLogger {
|
||||||
|
|
||||||
// Format date from ISO timestamp
|
// Format date from ISO timestamp
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
const formattedDate = date.toISOString().replace('T', ' ').slice(0, 19);
|
const formattedDate = date.toISOString().replace("T", " ").slice(0, 19);
|
||||||
|
|
||||||
let logText = `## ${formattedDate}\n`;
|
let logText = `## ${formattedDate}\n`;
|
||||||
logText += `${orgId}/${projectId}\n\n`;
|
logText += `${orgId}/${projectId}\n\n`;
|
||||||
|
|
@ -95,7 +104,7 @@ export class EnhancementLogger {
|
||||||
|
|
||||||
// Add tags if present
|
// Add tags if present
|
||||||
if (meta?.tags && meta.tags.length > 0) {
|
if (meta?.tags && meta.tags.length > 0) {
|
||||||
logText += `**Tags:** ${meta.tags.join(', ')}\n\n`;
|
logText += `**Tags:** ${meta.tags.join(", ")}\n\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
logText += `**Template:** ${template}\n`;
|
logText += `**Template:** ${template}\n`;
|
||||||
|
|
@ -103,7 +112,7 @@ export class EnhancementLogger {
|
||||||
logText += `**Language:** ${detectedLanguage}\n`;
|
logText += `**Language:** ${detectedLanguage}\n`;
|
||||||
}
|
}
|
||||||
if (enhancements.length > 0) {
|
if (enhancements.length > 0) {
|
||||||
logText += `**Enhancements:** ${enhancements.join(', ')}\n`;
|
logText += `**Enhancements:** ${enhancements.join(", ")}\n`;
|
||||||
}
|
}
|
||||||
logText += `**Model:** ${model}\n\n`;
|
logText += `**Model:** ${model}\n\n`;
|
||||||
logText += `---\n\n`;
|
logText += `---\n\n`;
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,18 @@ import {
|
||||||
PromptEnhancementOptions,
|
PromptEnhancementOptions,
|
||||||
PromptEnhancementContext,
|
PromptEnhancementContext,
|
||||||
PromptEnhancementResult,
|
PromptEnhancementResult,
|
||||||
} from './types';
|
} from "./types";
|
||||||
import { getAgent } from './agents';
|
import { getAgent } from "./agents";
|
||||||
import { validatePromptLength } from './validators';
|
import { validatePromptLength } from "./validators";
|
||||||
import { EnhancementLogger } from './EnhancementLogger';
|
import { EnhancementLogger } from "./EnhancementLogger";
|
||||||
import { NetworkErrorDetector } from '../../utils/NetworkErrorDetector';
|
|
||||||
|
|
||||||
export class PromptEnhancementService {
|
export class PromptEnhancementService {
|
||||||
private apiKey: string;
|
private apiKey: string;
|
||||||
private model = 'gemini-2.5-flash';
|
private model = "gemini-2.5-flash";
|
||||||
|
|
||||||
constructor(apiKey: string) {
|
constructor(apiKey: string) {
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error('Gemini API key is required');
|
throw new Error("Gemini API key is required");
|
||||||
}
|
}
|
||||||
this.apiKey = apiKey;
|
this.apiKey = apiKey;
|
||||||
}
|
}
|
||||||
|
|
@ -29,9 +28,11 @@ export class PromptEnhancementService {
|
||||||
console.log(
|
console.log(
|
||||||
`[${timestamp}] Starting prompt enhancement for: "${rawPrompt.substring(0, 50)}..."`,
|
`[${timestamp}] Starting prompt enhancement for: "${rawPrompt.substring(0, 50)}..."`,
|
||||||
);
|
);
|
||||||
console.log(`[${timestamp}] Template: ${options.template || 'general (auto-select)'}`);
|
console.log(
|
||||||
|
`[${timestamp}] Template: ${options.template || "general (auto-select)"}`,
|
||||||
|
);
|
||||||
if (options.tags && options.tags.length > 0) {
|
if (options.tags && options.tags.length > 0) {
|
||||||
console.log(`[${timestamp}] Tags: ${options.tags.join(', ')}`);
|
console.log(`[${timestamp}] Tags: ${options.tags.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-validate input prompt
|
// Pre-validate input prompt
|
||||||
|
|
@ -40,7 +41,7 @@ export class PromptEnhancementService {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
originalPrompt: rawPrompt,
|
originalPrompt: rawPrompt,
|
||||||
error: inputValidation.error || 'Validation failed',
|
error: inputValidation.error || "Validation failed",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,17 +56,23 @@ export class PromptEnhancementService {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
originalPrompt: rawPrompt,
|
originalPrompt: rawPrompt,
|
||||||
error: agentResult.error || 'Enhancement failed',
|
error: agentResult.error || "Enhancement failed",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post-validate enhanced prompt length
|
// Post-validate enhanced prompt length
|
||||||
const outputValidation = validatePromptLength(agentResult.enhancedPrompt, 2000);
|
const outputValidation = validatePromptLength(
|
||||||
|
agentResult.enhancedPrompt,
|
||||||
|
2000,
|
||||||
|
);
|
||||||
if (!outputValidation.valid) {
|
if (!outputValidation.valid) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[${timestamp}] Enhanced prompt exceeds 2000 characters (${agentResult.enhancedPrompt.length}), truncating...`,
|
`[${timestamp}] Enhanced prompt exceeds 2000 characters (${agentResult.enhancedPrompt.length}), truncating...`,
|
||||||
);
|
);
|
||||||
agentResult.enhancedPrompt = agentResult.enhancedPrompt.substring(0, 2000);
|
agentResult.enhancedPrompt = agentResult.enhancedPrompt.substring(
|
||||||
|
0,
|
||||||
|
2000,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: PromptEnhancementResult = {
|
const result: PromptEnhancementResult = {
|
||||||
|
|
@ -75,9 +82,10 @@ export class PromptEnhancementService {
|
||||||
...(agentResult.detectedLanguage && {
|
...(agentResult.detectedLanguage && {
|
||||||
detectedLanguage: agentResult.detectedLanguage,
|
detectedLanguage: agentResult.detectedLanguage,
|
||||||
}),
|
}),
|
||||||
appliedTemplate: agentResult.appliedTemplate || options.template || 'general',
|
appliedTemplate:
|
||||||
|
agentResult.appliedTemplate || options.template || "general",
|
||||||
metadata: {
|
metadata: {
|
||||||
style: agentResult.appliedTemplate || options.template || 'general',
|
style: agentResult.appliedTemplate || options.template || "general",
|
||||||
enhancements: agentResult.enhancements,
|
enhancements: agentResult.enhancements,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -91,7 +99,8 @@ export class PromptEnhancementService {
|
||||||
originalPrompt: rawPrompt,
|
originalPrompt: rawPrompt,
|
||||||
enhancedPrompt: agentResult.enhancedPrompt,
|
enhancedPrompt: agentResult.enhancedPrompt,
|
||||||
...(context.meta && { meta: context.meta }),
|
...(context.meta && { meta: context.meta }),
|
||||||
template: agentResult.appliedTemplate || options.template || 'general',
|
template:
|
||||||
|
agentResult.appliedTemplate || options.template || "general",
|
||||||
...(agentResult.detectedLanguage && {
|
...(agentResult.detectedLanguage && {
|
||||||
detectedLanguage: agentResult.detectedLanguage,
|
detectedLanguage: agentResult.detectedLanguage,
|
||||||
}),
|
}),
|
||||||
|
|
@ -103,28 +112,11 @@ export class PromptEnhancementService {
|
||||||
console.log(`[${timestamp}] Enhancement completed successfully`);
|
console.log(`[${timestamp}] Enhancement completed successfully`);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Enhanced error detection with network diagnostics
|
|
||||||
if (error instanceof Error) {
|
|
||||||
// Classify the error and check for network issues (only on failure)
|
|
||||||
const errorAnalysis = await NetworkErrorDetector.classifyError(error, 'Gemini API');
|
|
||||||
|
|
||||||
// Log the detailed error for debugging
|
|
||||||
console.error(
|
|
||||||
`[${timestamp}] [PromptEnhancementService] ${NetworkErrorDetector.formatErrorForLogging(errorAnalysis)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
originalPrompt: rawPrompt,
|
|
||||||
error: errorAnalysis.userMessage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error(`[${timestamp}] Prompt enhancement failed:`, error);
|
console.error(`[${timestamp}] Prompt enhancement failed:`, error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
originalPrompt: rawPrompt,
|
originalPrompt: rawPrompt,
|
||||||
error: 'Enhancement failed',
|
error: error instanceof Error ? error.message : "Enhancement failed",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { PromptEnhancementService } from '../PromptEnhancementService';
|
|
||||||
import type { AgentResult } from '../types';
|
|
||||||
|
|
||||||
// Mock the agents module
|
|
||||||
vi.mock('../agents', () => ({
|
|
||||||
getAgent: vi.fn(() => ({
|
|
||||||
templateType: 'general',
|
|
||||||
enhance: vi.fn(),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('PromptEnhancementService', () => {
|
|
||||||
let service: PromptEnhancementService;
|
|
||||||
const mockApiKey = 'test-gemini-api-key';
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
service = new PromptEnhancementService(mockApiKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('constructor', () => {
|
|
||||||
it('should throw error when API key is missing', () => {
|
|
||||||
expect(() => new PromptEnhancementService('')).toThrow('Gemini API key is required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create instance with valid API key', () => {
|
|
||||||
expect(service).toBeInstanceOf(PromptEnhancementService);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('enhancePrompt', () => {
|
|
||||||
it('should reject empty prompts', async () => {
|
|
||||||
const result = await service.enhancePrompt('');
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.error).toBe('Prompt cannot be empty');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject prompts exceeding 5000 characters', async () => {
|
|
||||||
const longPrompt = 'a'.repeat(5001);
|
|
||||||
const result = await service.enhancePrompt(longPrompt);
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.error).toContain('exceeds maximum length');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should successfully enhance valid prompt', async () => {
|
|
||||||
const { getAgent } = await import('../agents');
|
|
||||||
const mockAgent = {
|
|
||||||
templateType: 'general',
|
|
||||||
enhance: vi.fn().mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
enhancedPrompt: 'A beautiful sunset over the ocean with vibrant colors',
|
|
||||||
appliedTemplate: 'general',
|
|
||||||
enhancements: ['Added detailed descriptions'],
|
|
||||||
} as AgentResult),
|
|
||||||
};
|
|
||||||
vi.mocked(getAgent).mockReturnValue(mockAgent);
|
|
||||||
|
|
||||||
const result = await service.enhancePrompt('sunset');
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.enhancedPrompt).toBe('A beautiful sunset over the ocean with vibrant colors');
|
|
||||||
expect(result.appliedTemplate).toBe('general');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle agent enhancement failure', async () => {
|
|
||||||
const { getAgent } = await import('../agents');
|
|
||||||
const mockAgent = {
|
|
||||||
templateType: 'general',
|
|
||||||
enhance: vi.fn().mockResolvedValue({
|
|
||||||
success: false,
|
|
||||||
error: 'Enhancement failed',
|
|
||||||
enhancements: [],
|
|
||||||
} as AgentResult),
|
|
||||||
};
|
|
||||||
vi.mocked(getAgent).mockReturnValue(mockAgent);
|
|
||||||
|
|
||||||
const result = await service.enhancePrompt('sunset');
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.error).toBe('Enhancement failed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should truncate enhanced prompt exceeding 2000 characters', async () => {
|
|
||||||
const { getAgent } = await import('../agents');
|
|
||||||
const longEnhancedPrompt = 'enhanced '.repeat(300); // > 2000 chars
|
|
||||||
const mockAgent = {
|
|
||||||
templateType: 'general',
|
|
||||||
enhance: vi.fn().mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
enhancedPrompt: longEnhancedPrompt,
|
|
||||||
appliedTemplate: 'general',
|
|
||||||
enhancements: [],
|
|
||||||
} as AgentResult),
|
|
||||||
};
|
|
||||||
vi.mocked(getAgent).mockReturnValue(mockAgent);
|
|
||||||
|
|
||||||
const result = await service.enhancePrompt('sunset');
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.enhancedPrompt?.length).toBe(2000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should pass options to agent', async () => {
|
|
||||||
const { getAgent } = await import('../agents');
|
|
||||||
const mockEnhance = vi.fn().mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
enhancedPrompt: 'Enhanced',
|
|
||||||
appliedTemplate: 'photorealistic',
|
|
||||||
enhancements: [],
|
|
||||||
} as AgentResult);
|
|
||||||
const mockAgent = {
|
|
||||||
templateType: 'photorealistic',
|
|
||||||
enhance: mockEnhance,
|
|
||||||
};
|
|
||||||
vi.mocked(getAgent).mockReturnValue(mockAgent);
|
|
||||||
|
|
||||||
await service.enhancePrompt('sunset', {
|
|
||||||
template: 'photorealistic',
|
|
||||||
tags: ['landscape', 'nature'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockEnhance).toHaveBeenCalledWith('sunset', {
|
|
||||||
template: 'photorealistic',
|
|
||||||
tags: ['landscape', 'nature'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include detected language in result', async () => {
|
|
||||||
const { getAgent } = await import('../agents');
|
|
||||||
const mockAgent = {
|
|
||||||
templateType: 'general',
|
|
||||||
enhance: vi.fn().mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
enhancedPrompt: 'Enhanced prompt',
|
|
||||||
detectedLanguage: 'Spanish',
|
|
||||||
appliedTemplate: 'general',
|
|
||||||
enhancements: [],
|
|
||||||
} as AgentResult),
|
|
||||||
};
|
|
||||||
vi.mocked(getAgent).mockReturnValue(mockAgent);
|
|
||||||
|
|
||||||
const result = await service.enhancePrompt('hermosa puesta de sol');
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.detectedLanguage).toBe('Spanish');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle exceptions gracefully', async () => {
|
|
||||||
const { getAgent } = await import('../agents');
|
|
||||||
const mockAgent = {
|
|
||||||
templateType: 'general',
|
|
||||||
enhance: vi.fn().mockRejectedValue(new Error('Network error')),
|
|
||||||
};
|
|
||||||
vi.mocked(getAgent).mockReturnValue(mockAgent);
|
|
||||||
|
|
||||||
const result = await service.enhancePrompt('sunset');
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.error).toBe('Network error');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return original prompt in all results', async () => {
|
|
||||||
const originalPrompt = 'test prompt';
|
|
||||||
const result = await service.enhancePrompt(originalPrompt);
|
|
||||||
expect(result.originalPrompt).toBe(originalPrompt);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { getAgent } from '../agents';
|
|
||||||
import {
|
|
||||||
GeneralAgent,
|
|
||||||
PhotorealisticAgent,
|
|
||||||
IllustrationAgent,
|
|
||||||
MinimalistAgent,
|
|
||||||
StickerAgent,
|
|
||||||
ProductAgent,
|
|
||||||
ComicAgent,
|
|
||||||
} from '../agents';
|
|
||||||
|
|
||||||
describe('getAgent', () => {
|
|
||||||
const mockApiKey = 'test-api-key';
|
|
||||||
|
|
||||||
it('should return GeneralAgent when no template provided', () => {
|
|
||||||
const agent = getAgent(mockApiKey);
|
|
||||||
expect(agent).toBeInstanceOf(GeneralAgent);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return PhotorealisticAgent for photorealistic template', () => {
|
|
||||||
const agent = getAgent(mockApiKey, 'photorealistic');
|
|
||||||
expect(agent).toBeInstanceOf(PhotorealisticAgent);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return IllustrationAgent for illustration template', () => {
|
|
||||||
const agent = getAgent(mockApiKey, 'illustration');
|
|
||||||
expect(agent).toBeInstanceOf(IllustrationAgent);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return MinimalistAgent for minimalist template', () => {
|
|
||||||
const agent = getAgent(mockApiKey, 'minimalist');
|
|
||||||
expect(agent).toBeInstanceOf(MinimalistAgent);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return StickerAgent for sticker template', () => {
|
|
||||||
const agent = getAgent(mockApiKey, 'sticker');
|
|
||||||
expect(agent).toBeInstanceOf(StickerAgent);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return ProductAgent for product template', () => {
|
|
||||||
const agent = getAgent(mockApiKey, 'product');
|
|
||||||
expect(agent).toBeInstanceOf(ProductAgent);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return ComicAgent for comic template', () => {
|
|
||||||
const agent = getAgent(mockApiKey, 'comic');
|
|
||||||
expect(agent).toBeInstanceOf(ComicAgent);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return GeneralAgent for general template', () => {
|
|
||||||
const agent = getAgent(mockApiKey, 'general');
|
|
||||||
expect(agent).toBeInstanceOf(GeneralAgent);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fallback to GeneralAgent for unknown template', () => {
|
|
||||||
const agent = getAgent(mockApiKey, 'unknown-template' as any);
|
|
||||||
expect(agent).toBeInstanceOf(GeneralAgent);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return agent with correct templateType', () => {
|
|
||||||
const agent = getAgent(mockApiKey, 'photorealistic');
|
|
||||||
expect(agent.templateType).toBe('photorealistic');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { detectLanguage, detectEnhancements } from '../utils';
|
|
||||||
|
|
||||||
describe('detectLanguage', () => {
|
|
||||||
it('should detect English', () => {
|
|
||||||
expect(detectLanguage('a sunset over the ocean')).toBe('English');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect Chinese', () => {
|
|
||||||
expect(detectLanguage('美丽的日落')).toBe('Chinese');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect Japanese', () => {
|
|
||||||
expect(detectLanguage('こんにちは')).toBe('Japanese');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect Korean', () => {
|
|
||||||
expect(detectLanguage('아름다운 일몰')).toBe('Korean');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect Romance Language', () => {
|
|
||||||
expect(detectLanguage('hermosa puesta de sól')).toBe('Romance Language');
|
|
||||||
expect(detectLanguage('beau coucher de soléil')).toBe('Romance Language');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect Russian', () => {
|
|
||||||
expect(detectLanguage('красивый закат')).toBe('Russian');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect Greek', () => {
|
|
||||||
expect(detectLanguage('όμορφο ηλιοβασίλεμα')).toBe('Greek');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect Arabic', () => {
|
|
||||||
expect(detectLanguage('غروب جميل')).toBe('Arabic');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect Hebrew', () => {
|
|
||||||
expect(detectLanguage('שקיעה יפה')).toBe('Hebrew');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should default to English for unknown scripts', () => {
|
|
||||||
expect(detectLanguage('123')).toBe('English');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('detectEnhancements', () => {
|
|
||||||
it('should detect added detailed descriptions', () => {
|
|
||||||
const original = 'sunset';
|
|
||||||
const enhanced = 'A breathtaking sunset over the ocean with vibrant orange and pink hues';
|
|
||||||
const enhancements = detectEnhancements(original, enhanced);
|
|
||||||
expect(enhancements).toContain('Added detailed descriptions');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect photography terminology', () => {
|
|
||||||
const original = 'sunset';
|
|
||||||
const enhanced = 'A photorealistic sunset shot with 50mm lens';
|
|
||||||
const enhancements = detectEnhancements(original, enhanced);
|
|
||||||
expect(enhancements).toContain('Applied photography terminology');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect lighting descriptions', () => {
|
|
||||||
const original = 'room';
|
|
||||||
const enhanced = 'A room with soft lighting and warm illuminated corners';
|
|
||||||
const enhancements = detectEnhancements(original, enhanced);
|
|
||||||
expect(enhancements).toContain('Enhanced lighting description');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect texture details', () => {
|
|
||||||
const original = 'wall';
|
|
||||||
const enhanced = 'A wall with rough texture and weathered surface';
|
|
||||||
const enhancements = detectEnhancements(original, enhanced);
|
|
||||||
expect(enhancements).toContain('Added texture details');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect multiple enhancements', () => {
|
|
||||||
const original = 'sunset';
|
|
||||||
const enhanced =
|
|
||||||
'A photorealistic sunset with dramatic lighting, beautiful texture in the clouds, and soft illuminated sky';
|
|
||||||
const enhancements = detectEnhancements(original, enhanced);
|
|
||||||
expect(enhancements.length).toBeGreaterThan(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty array when no enhancements detected', () => {
|
|
||||||
const original = 'sunset';
|
|
||||||
const enhanced = 'sunset';
|
|
||||||
const enhancements = detectEnhancements(original, enhanced);
|
|
||||||
expect(enhancements).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { validatePromptLength } from '../validators';
|
|
||||||
|
|
||||||
describe('validatePromptLength', () => {
|
|
||||||
it('should reject empty prompts', () => {
|
|
||||||
const result = validatePromptLength('');
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
expect(result.error).toBe('Prompt cannot be empty');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject whitespace-only prompts', () => {
|
|
||||||
const result = validatePromptLength(' ');
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
expect(result.error).toBe('Prompt cannot be empty');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept valid prompt within max length', () => {
|
|
||||||
const result = validatePromptLength('a sunset over the ocean', 2000);
|
|
||||||
expect(result.valid).toBe(true);
|
|
||||||
expect(result.error).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject prompts exceeding max length', () => {
|
|
||||||
const longPrompt = 'a'.repeat(2001);
|
|
||||||
const result = validatePromptLength(longPrompt, 2000);
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
expect(result.error).toContain('exceeds maximum length');
|
|
||||||
expect(result.error).toContain('2000');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use custom max length', () => {
|
|
||||||
const prompt = 'a'.repeat(150);
|
|
||||||
const result = validatePromptLength(prompt, 100);
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
expect(result.error).toContain('100');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept prompt at exact max length', () => {
|
|
||||||
const prompt = 'a'.repeat(100);
|
|
||||||
const result = validatePromptLength(prompt, 100);
|
|
||||||
expect(result.valid).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,16 +1,20 @@
|
||||||
import { GoogleGenAI } from '@google/genai';
|
import { GoogleGenAI } from "@google/genai";
|
||||||
import { IPromptAgent, PromptEnhancementOptions, AgentResult } from '../types';
|
import {
|
||||||
import { detectLanguage, detectEnhancements } from '../utils';
|
IPromptAgent,
|
||||||
|
PromptEnhancementOptions,
|
||||||
|
AgentResult,
|
||||||
|
} from "../types";
|
||||||
|
import { detectLanguage, detectEnhancements } from "../utils";
|
||||||
|
|
||||||
export abstract class BaseAgent implements IPromptAgent {
|
export abstract class BaseAgent implements IPromptAgent {
|
||||||
protected ai: GoogleGenAI;
|
protected ai: GoogleGenAI;
|
||||||
protected model = 'gemini-2.5-flash';
|
protected model = "gemini-2.5-flash";
|
||||||
|
|
||||||
abstract readonly templateType: string;
|
abstract readonly templateType: string;
|
||||||
|
|
||||||
constructor(apiKey: string) {
|
constructor(apiKey: string) {
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error('Gemini API key is required');
|
throw new Error("Gemini API key is required");
|
||||||
}
|
}
|
||||||
this.ai = new GoogleGenAI({ apiKey });
|
this.ai = new GoogleGenAI({ apiKey });
|
||||||
}
|
}
|
||||||
|
|
@ -18,7 +22,10 @@ export abstract class BaseAgent implements IPromptAgent {
|
||||||
protected abstract getSystemPrompt(): string;
|
protected abstract getSystemPrompt(): string;
|
||||||
protected abstract getTemplate(): string;
|
protected abstract getTemplate(): string;
|
||||||
|
|
||||||
async enhance(rawPrompt: string, _options: PromptEnhancementOptions): Promise<AgentResult> {
|
async enhance(
|
||||||
|
rawPrompt: string,
|
||||||
|
_options: PromptEnhancementOptions,
|
||||||
|
): Promise<AgentResult> {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
|
|
@ -31,20 +38,26 @@ export abstract class BaseAgent implements IPromptAgent {
|
||||||
|
|
||||||
const response = await this.ai.models.generateContent({
|
const response = await this.ai.models.generateContent({
|
||||||
model: this.model,
|
model: this.model,
|
||||||
config: { responseModalities: ['TEXT'] },
|
config: { responseModalities: ["TEXT"] },
|
||||||
contents: [
|
contents: [
|
||||||
{
|
{
|
||||||
role: 'user' as const,
|
role: "user" as const,
|
||||||
parts: [{ text: `${systemPrompt}\n\n${userPrompt}` }],
|
parts: [{ text: `${systemPrompt}\n\n${userPrompt}` }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.candidates && response.candidates[0] && response.candidates[0].content) {
|
if (
|
||||||
|
response.candidates &&
|
||||||
|
response.candidates[0] &&
|
||||||
|
response.candidates[0].content
|
||||||
|
) {
|
||||||
const content = response.candidates[0].content;
|
const content = response.candidates[0].content;
|
||||||
const enhancedPrompt = content.parts?.[0]?.text?.trim() || '';
|
const enhancedPrompt = content.parts?.[0]?.text?.trim() || "";
|
||||||
|
|
||||||
console.log(`[${timestamp}] [${this.templateType}Agent] Enhancement successful`);
|
console.log(
|
||||||
|
`[${timestamp}] [${this.templateType}Agent] Enhancement successful`,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -57,14 +70,17 @@ export abstract class BaseAgent implements IPromptAgent {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'No enhanced prompt received from API',
|
error: "No enhanced prompt received from API",
|
||||||
enhancements: [],
|
enhancements: [],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[${timestamp}] [${this.templateType}Agent] Enhancement failed:`, error);
|
console.error(
|
||||||
|
`[${timestamp}] [${this.templateType}Agent] Enhancement failed:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Enhancement failed',
|
error: error instanceof Error ? error.message : "Enhancement failed",
|
||||||
enhancements: [],
|
enhancements: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { BaseAgent } from './BaseAgent';
|
import { BaseAgent } from "./BaseAgent";
|
||||||
|
|
||||||
export const COMIC_TEMPLATE = `A single comic book panel in a [art style] style. In the foreground, [character description and action]. In the background, [setting details]. The panel has a [dialogue/caption box] with the text "[Text]". The lighting creates a [mood] mood. [Aspect ratio].
|
export const COMIC_TEMPLATE = `A single comic book panel in a [art style] style. In the foreground, [character description and action]. In the background, [setting details]. The panel has a [dialogue/caption box] with the text "[Text]". The lighting creates a [mood] mood. [Aspect ratio].
|
||||||
|
|
||||||
|
|
@ -6,7 +6,7 @@ Example:
|
||||||
A single comic book panel in a gritty, noir art style with high-contrast black and white inks. In the foreground, a detective in a trench coat stands under a flickering streetlamp, rain soaking his shoulders. In the background, the neon sign of a desolate bar reflects in a puddle. A caption box at the top reads "The city was a tough place to keep secrets." The lighting is harsh, creating a dramatic, somber mood. Landscape.`;
|
A single comic book panel in a gritty, noir art style with high-contrast black and white inks. In the foreground, a detective in a trench coat stands under a flickering streetlamp, rain soaking his shoulders. In the background, the neon sign of a desolate bar reflects in a puddle. A caption box at the top reads "The city was a tough place to keep secrets." The lighting is harsh, creating a dramatic, somber mood. Landscape.`;
|
||||||
|
|
||||||
export class ComicAgent extends BaseAgent {
|
export class ComicAgent extends BaseAgent {
|
||||||
readonly templateType = 'comic';
|
readonly templateType = "comic";
|
||||||
|
|
||||||
protected getTemplate(): string {
|
protected getTemplate(): string {
|
||||||
return COMIC_TEMPLATE;
|
return COMIC_TEMPLATE;
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { BaseAgent } from './BaseAgent';
|
import { BaseAgent } from "./BaseAgent";
|
||||||
import { PHOTOREALISTIC_TEMPLATE } from './PhotorealisticAgent';
|
import { PHOTOREALISTIC_TEMPLATE } from "./PhotorealisticAgent";
|
||||||
import { ILLUSTRATION_TEMPLATE } from './IllustrationAgent';
|
import { ILLUSTRATION_TEMPLATE } from "./IllustrationAgent";
|
||||||
import { MINIMALIST_TEMPLATE } from './MinimalistAgent';
|
import { MINIMALIST_TEMPLATE } from "./MinimalistAgent";
|
||||||
import { STICKER_TEMPLATE } from './StickerAgent';
|
import { STICKER_TEMPLATE } from "./StickerAgent";
|
||||||
import { PRODUCT_TEMPLATE } from './ProductAgent';
|
import { PRODUCT_TEMPLATE } from "./ProductAgent";
|
||||||
import { COMIC_TEMPLATE } from './ComicAgent';
|
import { COMIC_TEMPLATE } from "./ComicAgent";
|
||||||
|
|
||||||
export class GeneralAgent extends BaseAgent {
|
export class GeneralAgent extends BaseAgent {
|
||||||
readonly templateType = 'general';
|
readonly templateType = "general";
|
||||||
|
|
||||||
protected getTemplate(): string {
|
protected getTemplate(): string {
|
||||||
return `AVAILABLE TEMPLATES:
|
return `AVAILABLE TEMPLATES:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { BaseAgent } from './BaseAgent';
|
import { BaseAgent } from "./BaseAgent";
|
||||||
|
|
||||||
export const ILLUSTRATION_TEMPLATE = `A [style] illustration of [subject], featuring [key characteristics] with [color palette]. The art style is [art technique description], with [line work style] and [shading technique]. The composition includes [composition details].
|
export const ILLUSTRATION_TEMPLATE = `A [style] illustration of [subject], featuring [key characteristics] with [color palette]. The art style is [art technique description], with [line work style] and [shading technique]. The composition includes [composition details].
|
||||||
|
|
||||||
|
|
@ -6,7 +6,7 @@ Example:
|
||||||
A watercolor illustration of a magical forest clearing at twilight, featuring glowing fireflies and an ancient stone archway covered in luminescent moss. The art style is whimsical and dreamlike, with soft, flowing brushstrokes and gentle color bleeding. The color palette consists of deep purples, soft blues, and warm golden yellows. The illustration uses delicate line work for fine details and subtle wet-on-wet shading to create atmospheric depth.`;
|
A watercolor illustration of a magical forest clearing at twilight, featuring glowing fireflies and an ancient stone archway covered in luminescent moss. The art style is whimsical and dreamlike, with soft, flowing brushstrokes and gentle color bleeding. The color palette consists of deep purples, soft blues, and warm golden yellows. The illustration uses delicate line work for fine details and subtle wet-on-wet shading to create atmospheric depth.`;
|
||||||
|
|
||||||
export class IllustrationAgent extends BaseAgent {
|
export class IllustrationAgent extends BaseAgent {
|
||||||
readonly templateType = 'illustration';
|
readonly templateType = "illustration";
|
||||||
|
|
||||||
protected getTemplate(): string {
|
protected getTemplate(): string {
|
||||||
return ILLUSTRATION_TEMPLATE;
|
return ILLUSTRATION_TEMPLATE;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { BaseAgent } from './BaseAgent';
|
import { BaseAgent } from "./BaseAgent";
|
||||||
|
|
||||||
export const MINIMALIST_TEMPLATE = `A minimalist composition featuring a single [subject] positioned in the [position in frame] of the frame. The background is a vast, empty [color/description] canvas, creating significant negative space. Soft, subtle lighting. [Aspect ratio].
|
export const MINIMALIST_TEMPLATE = `A minimalist composition featuring a single [subject] positioned in the [position in frame] of the frame. The background is a vast, empty [color/description] canvas, creating significant negative space. Soft, subtle lighting. [Aspect ratio].
|
||||||
|
|
||||||
|
|
@ -6,7 +6,7 @@ Example:
|
||||||
A minimalist composition featuring a single, delicate red maple leaf positioned in the bottom-right of the frame. The background is a vast, empty off-white canvas, creating significant negative space for text. Soft, diffused lighting from the top left. Square image.`;
|
A minimalist composition featuring a single, delicate red maple leaf positioned in the bottom-right of the frame. The background is a vast, empty off-white canvas, creating significant negative space for text. Soft, diffused lighting from the top left. Square image.`;
|
||||||
|
|
||||||
export class MinimalistAgent extends BaseAgent {
|
export class MinimalistAgent extends BaseAgent {
|
||||||
readonly templateType = 'minimalist';
|
readonly templateType = "minimalist";
|
||||||
|
|
||||||
protected getTemplate(): string {
|
protected getTemplate(): string {
|
||||||
return MINIMALIST_TEMPLATE;
|
return MINIMALIST_TEMPLATE;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { BaseAgent } from './BaseAgent';
|
import { BaseAgent } from "./BaseAgent";
|
||||||
|
|
||||||
export const PHOTOREALISTIC_TEMPLATE = `A photorealistic [shot type] of [subject], [action or expression], set in [environment]. The scene is illuminated by [lighting description], creating a [mood] atmosphere. Captured with a [camera/lens details], emphasizing [key textures and details]. The image should be in a [aspect ratio] format.
|
export const PHOTOREALISTIC_TEMPLATE = `A photorealistic [shot type] of [subject], [action or expression], set in [environment]. The scene is illuminated by [lighting description], creating a [mood] atmosphere. Captured with a [camera/lens details], emphasizing [key textures and details]. The image should be in a [aspect ratio] format.
|
||||||
|
|
||||||
|
|
@ -6,7 +6,7 @@ Example:
|
||||||
A photorealistic close-up portrait of an elderly Japanese ceramicist with deep, sun-etched wrinkles and a warm, knowing smile. He is carefully inspecting a freshly glazed tea bowl. The setting is his rustic, sun-drenched workshop. The scene is illuminated by soft, golden hour light streaming through a window, highlighting the fine texture of the clay. Captured with an 85mm portrait lens, resulting in a soft, blurred background (bokeh). The overall mood is serene and masterful. Vertical portrait orientation.`;
|
A photorealistic close-up portrait of an elderly Japanese ceramicist with deep, sun-etched wrinkles and a warm, knowing smile. He is carefully inspecting a freshly glazed tea bowl. The setting is his rustic, sun-drenched workshop. The scene is illuminated by soft, golden hour light streaming through a window, highlighting the fine texture of the clay. Captured with an 85mm portrait lens, resulting in a soft, blurred background (bokeh). The overall mood is serene and masterful. Vertical portrait orientation.`;
|
||||||
|
|
||||||
export class PhotorealisticAgent extends BaseAgent {
|
export class PhotorealisticAgent extends BaseAgent {
|
||||||
readonly templateType = 'photorealistic';
|
readonly templateType = "photorealistic";
|
||||||
|
|
||||||
protected getTemplate(): string {
|
protected getTemplate(): string {
|
||||||
return PHOTOREALISTIC_TEMPLATE;
|
return PHOTOREALISTIC_TEMPLATE;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { BaseAgent } from './BaseAgent';
|
import { BaseAgent } from "./BaseAgent";
|
||||||
|
|
||||||
export const PRODUCT_TEMPLATE = `A high-resolution, studio-lit product photograph of a [product description] on a [background surface/description]. The lighting is a [lighting setup, e.g., three-point softbox setup] to [lighting purpose]. The camera angle is a [angle type] to showcase [specific feature]. Ultra-realistic, with sharp focus on [key detail]. [Aspect ratio].
|
export const PRODUCT_TEMPLATE = `A high-resolution, studio-lit product photograph of a [product description] on a [background surface/description]. The lighting is a [lighting setup, e.g., three-point softbox setup] to [lighting purpose]. The camera angle is a [angle type] to showcase [specific feature]. Ultra-realistic, with sharp focus on [key detail]. [Aspect ratio].
|
||||||
|
|
||||||
|
|
@ -6,7 +6,7 @@ Example:
|
||||||
A high-resolution, studio-lit product photograph of a minimalist ceramic coffee mug in matte black, presented on a polished concrete surface. The lighting is a three-point softbox setup designed to create soft, diffused highlights and eliminate harsh shadows. The camera angle is a slightly elevated 45-degree shot to showcase its clean lines. Ultra-realistic, with sharp focus on the steam rising from the coffee. Square image.`;
|
A high-resolution, studio-lit product photograph of a minimalist ceramic coffee mug in matte black, presented on a polished concrete surface. The lighting is a three-point softbox setup designed to create soft, diffused highlights and eliminate harsh shadows. The camera angle is a slightly elevated 45-degree shot to showcase its clean lines. Ultra-realistic, with sharp focus on the steam rising from the coffee. Square image.`;
|
||||||
|
|
||||||
export class ProductAgent extends BaseAgent {
|
export class ProductAgent extends BaseAgent {
|
||||||
readonly templateType = 'product';
|
readonly templateType = "product";
|
||||||
|
|
||||||
protected getTemplate(): string {
|
protected getTemplate(): string {
|
||||||
return PRODUCT_TEMPLATE;
|
return PRODUCT_TEMPLATE;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { BaseAgent } from './BaseAgent';
|
import { BaseAgent } from "./BaseAgent";
|
||||||
|
|
||||||
export const STICKER_TEMPLATE = `A [style] sticker of a [subject], featuring [key characteristics] and a [color palette]. The design should have [line style] and [shading style]. The background must be transparent.
|
export const STICKER_TEMPLATE = `A [style] sticker of a [subject], featuring [key characteristics] and a [color palette]. The design should have [line style] and [shading style]. The background must be transparent.
|
||||||
|
|
||||||
|
|
@ -6,7 +6,7 @@ Example:
|
||||||
A kawaii-style sticker of a happy red panda wearing a tiny bamboo hat. It's munching on a green bamboo leaf. The design features bold, clean outlines, simple cel-shading, and a vibrant color palette. The background must be white.`;
|
A kawaii-style sticker of a happy red panda wearing a tiny bamboo hat. It's munching on a green bamboo leaf. The design features bold, clean outlines, simple cel-shading, and a vibrant color palette. The background must be white.`;
|
||||||
|
|
||||||
export class StickerAgent extends BaseAgent {
|
export class StickerAgent extends BaseAgent {
|
||||||
readonly templateType = 'sticker';
|
readonly templateType = "sticker";
|
||||||
|
|
||||||
protected getTemplate(): string {
|
protected getTemplate(): string {
|
||||||
return STICKER_TEMPLATE;
|
return STICKER_TEMPLATE;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { IPromptAgent } from '../types';
|
import { IPromptAgent } from "../types";
|
||||||
import { PhotorealisticAgent } from './PhotorealisticAgent';
|
import { PhotorealisticAgent } from "./PhotorealisticAgent";
|
||||||
import { IllustrationAgent } from './IllustrationAgent';
|
import { IllustrationAgent } from "./IllustrationAgent";
|
||||||
import { MinimalistAgent } from './MinimalistAgent';
|
import { MinimalistAgent } from "./MinimalistAgent";
|
||||||
import { StickerAgent } from './StickerAgent';
|
import { StickerAgent } from "./StickerAgent";
|
||||||
import { ProductAgent } from './ProductAgent';
|
import { ProductAgent } from "./ProductAgent";
|
||||||
import { ComicAgent } from './ComicAgent';
|
import { ComicAgent } from "./ComicAgent";
|
||||||
import { GeneralAgent } from './GeneralAgent';
|
import { GeneralAgent } from "./GeneralAgent";
|
||||||
|
|
||||||
type AgentConstructor = new (apiKey: string) => IPromptAgent;
|
type AgentConstructor = new (apiKey: string) => IPromptAgent;
|
||||||
|
|
||||||
|
|
@ -26,7 +26,9 @@ export function getAgent(apiKey: string, template?: string): IPromptAgent {
|
||||||
|
|
||||||
const AgentClass = AGENT_REGISTRY[template];
|
const AgentClass = AGENT_REGISTRY[template];
|
||||||
if (!AgentClass) {
|
if (!AgentClass) {
|
||||||
console.warn(`Unknown template "${template}", falling back to GeneralAgent`);
|
console.warn(
|
||||||
|
`Unknown template "${template}", falling back to GeneralAgent`,
|
||||||
|
);
|
||||||
return new GeneralAgent(apiKey);
|
return new GeneralAgent(apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export { PromptEnhancementService } from './PromptEnhancementService';
|
export { PromptEnhancementService } from "./PromptEnhancementService";
|
||||||
export { EnhancementLogger } from './EnhancementLogger';
|
export { EnhancementLogger } from "./EnhancementLogger";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
PromptEnhancementOptions,
|
PromptEnhancementOptions,
|
||||||
|
|
@ -9,4 +9,4 @@ export type {
|
||||||
IPromptAgent,
|
IPromptAgent,
|
||||||
AgentResult,
|
AgentResult,
|
||||||
ValidationResult,
|
ValidationResult,
|
||||||
} from './types';
|
} from "./types";
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
export interface PromptEnhancementOptions {
|
export interface PromptEnhancementOptions {
|
||||||
template?:
|
template?:
|
||||||
| 'photorealistic'
|
| "photorealistic"
|
||||||
| 'illustration'
|
| "illustration"
|
||||||
| 'minimalist'
|
| "minimalist"
|
||||||
| 'sticker'
|
| "sticker"
|
||||||
| 'product'
|
| "product"
|
||||||
| 'comic'
|
| "comic"
|
||||||
| 'general';
|
| "general";
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,7 +43,10 @@ export interface AgentResult {
|
||||||
|
|
||||||
export interface IPromptAgent {
|
export interface IPromptAgent {
|
||||||
readonly templateType: string;
|
readonly templateType: string;
|
||||||
enhance(prompt: string, options: PromptEnhancementOptions): Promise<AgentResult>;
|
enhance(
|
||||||
|
prompt: string,
|
||||||
|
options: PromptEnhancementOptions,
|
||||||
|
): Promise<AgentResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ValidationResult {
|
export interface ValidationResult {
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,46 @@
|
||||||
export function detectLanguage(text: string): string {
|
export function detectLanguage(text: string): string {
|
||||||
if (/[\u4e00-\u9fff]/.test(text)) return 'Chinese';
|
if (/[\u4e00-\u9fff]/.test(text)) return "Chinese";
|
||||||
if (/[\u3040-\u309f\u30a0-\u30ff]/.test(text)) return 'Japanese';
|
if (/[\u3040-\u309f\u30a0-\u30ff]/.test(text)) return "Japanese";
|
||||||
if (/[\uac00-\ud7af]/.test(text)) return 'Korean';
|
if (/[\uac00-\ud7af]/.test(text)) return "Korean";
|
||||||
if (/[àáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(text)) return 'Romance Language';
|
if (/[àáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(text))
|
||||||
if (/[а-яё]/.test(text.toLowerCase())) return 'Russian';
|
return "Romance Language";
|
||||||
if (/[α-ωΑ-Ω]/.test(text)) return 'Greek';
|
if (/[а-яё]/.test(text.toLowerCase())) return "Russian";
|
||||||
if (/[أ-ي]/.test(text)) return 'Arabic';
|
if (/[α-ωΑ-Ω]/.test(text)) return "Greek";
|
||||||
if (/[א-ת]/.test(text)) return 'Hebrew';
|
if (/[أ-ي]/.test(text)) return "Arabic";
|
||||||
return 'English';
|
if (/[א-ת]/.test(text)) return "Hebrew";
|
||||||
|
return "English";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function detectEnhancements(originalPrompt: string, enhancedPrompt: string): string[] {
|
export function detectEnhancements(
|
||||||
|
originalPrompt: string,
|
||||||
|
enhancedPrompt: string,
|
||||||
|
): string[] {
|
||||||
const enhancements: string[] = [];
|
const enhancements: string[] = [];
|
||||||
|
|
||||||
if (enhancedPrompt.length > originalPrompt.length * 1.5) {
|
if (enhancedPrompt.length > originalPrompt.length * 1.5) {
|
||||||
enhancements.push('Added detailed descriptions');
|
enhancements.push("Added detailed descriptions");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
enhancedPrompt.includes('photorealistic') ||
|
enhancedPrompt.includes("photorealistic") ||
|
||||||
enhancedPrompt.includes('shot') ||
|
enhancedPrompt.includes("shot") ||
|
||||||
enhancedPrompt.includes('lens')
|
enhancedPrompt.includes("lens")
|
||||||
) {
|
) {
|
||||||
enhancements.push('Applied photography terminology');
|
enhancements.push("Applied photography terminology");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enhancedPrompt.includes('lighting') || enhancedPrompt.includes('illuminated')) {
|
if (
|
||||||
enhancements.push('Enhanced lighting description');
|
enhancedPrompt.includes("lighting") ||
|
||||||
|
enhancedPrompt.includes("illuminated")
|
||||||
|
) {
|
||||||
|
enhancements.push("Enhanced lighting description");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enhancedPrompt.includes('texture') || enhancedPrompt.includes('surface')) {
|
if (
|
||||||
enhancements.push('Added texture details');
|
enhancedPrompt.includes("texture") ||
|
||||||
|
enhancedPrompt.includes("surface")
|
||||||
|
) {
|
||||||
|
enhancements.push("Added texture details");
|
||||||
}
|
}
|
||||||
|
|
||||||
return enhancements;
|
return enhancements;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { ValidationResult } from './types';
|
import { ValidationResult } from "./types";
|
||||||
|
|
||||||
export function validatePromptLength(prompt: string, maxLength: number = 2000): ValidationResult {
|
export function validatePromptLength(
|
||||||
|
prompt: string,
|
||||||
|
maxLength: number = 2000,
|
||||||
|
): ValidationResult {
|
||||||
if (!prompt || prompt.trim().length === 0) {
|
if (!prompt || prompt.trim().length === 0) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
error: 'Prompt cannot be empty',
|
error: "Prompt cannot be empty",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Request } from 'express';
|
import { Request } from "express";
|
||||||
|
|
||||||
// API Request/Response types
|
// API Request/Response types
|
||||||
export interface GenerateImageRequest {
|
export interface GenerateImageRequest {
|
||||||
|
|
@ -13,13 +13,13 @@ export interface TextToImageRequest {
|
||||||
autoEnhance?: boolean; // Defaults to true
|
autoEnhance?: boolean; // Defaults to true
|
||||||
enhancementOptions?: {
|
enhancementOptions?: {
|
||||||
template?:
|
template?:
|
||||||
| 'photorealistic'
|
| "photorealistic"
|
||||||
| 'illustration'
|
| "illustration"
|
||||||
| 'minimalist'
|
| "minimalist"
|
||||||
| 'sticker'
|
| "sticker"
|
||||||
| 'product'
|
| "product"
|
||||||
| 'comic'
|
| "comic"
|
||||||
| 'general'; // Defaults to "photorealistic"
|
| "general"; // Defaults to "photorealistic"
|
||||||
};
|
};
|
||||||
meta?: {
|
meta?: {
|
||||||
tags?: string[]; // Optional array of tags for tracking/grouping (not stored, only logged)
|
tags?: string[]; // Optional array of tags for tracking/grouping (not stored, only logged)
|
||||||
|
|
@ -98,7 +98,7 @@ export interface ImageGenerationResult {
|
||||||
model: string;
|
model: string;
|
||||||
geminiParams?: GeminiParams; // Gemini SDK parameters used for generation
|
geminiParams?: GeminiParams; // Gemini SDK parameters used for generation
|
||||||
error?: string;
|
error?: string;
|
||||||
errorType?: 'generation' | 'storage'; // Distinguish between generation and storage errors
|
errorType?: "generation" | "storage"; // Distinguish between generation and storage errors
|
||||||
generatedImageData?: GeneratedImageData; // Available when generation succeeds but storage fails
|
generatedImageData?: GeneratedImageData; // Available when generation succeeds but storage fails
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,13 +123,13 @@ export interface PromptEnhancementRequest {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
options?: {
|
options?: {
|
||||||
template?:
|
template?:
|
||||||
| 'photorealistic'
|
| "photorealistic"
|
||||||
| 'illustration'
|
| "illustration"
|
||||||
| 'minimalist'
|
| "minimalist"
|
||||||
| 'sticker'
|
| "sticker"
|
||||||
| 'product'
|
| "product"
|
||||||
| 'comic'
|
| "comic"
|
||||||
| 'general'; // Defaults to "photorealistic"
|
| "general"; // Defaults to "photorealistic"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,13 +153,13 @@ export interface EnhancedGenerateImageRequest extends GenerateImageRequest {
|
||||||
autoEnhance?: boolean; // Defaults to true
|
autoEnhance?: boolean; // Defaults to true
|
||||||
enhancementOptions?: {
|
enhancementOptions?: {
|
||||||
template?:
|
template?:
|
||||||
| 'photorealistic'
|
| "photorealistic"
|
||||||
| 'illustration'
|
| "illustration"
|
||||||
| 'minimalist'
|
| "minimalist"
|
||||||
| 'sticker'
|
| "sticker"
|
||||||
| 'product'
|
| "product"
|
||||||
| 'comic'
|
| "comic"
|
||||||
| 'general'; // Defaults to "photorealistic"
|
| "general"; // Defaults to "photorealistic"
|
||||||
};
|
};
|
||||||
meta?: {
|
meta?: {
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
import * as dns from 'dns';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
|
|
||||||
const dnsLookup = promisify(dns.lookup);
|
|
||||||
|
|
||||||
export interface NetworkErrorResult {
|
|
||||||
isNetworkError: boolean;
|
|
||||||
errorType?: 'connection' | 'dns' | 'timeout' | 'refused' | 'reset' | 'unknown';
|
|
||||||
userMessage: string;
|
|
||||||
technicalDetails?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NetworkErrorDetector {
|
|
||||||
private static NETWORK_ERROR_CODES = [
|
|
||||||
'ENOTFOUND', // DNS resolution failed
|
|
||||||
'ETIMEDOUT', // Connection timeout
|
|
||||||
'ECONNREFUSED', // Connection refused
|
|
||||||
'ECONNRESET', // Connection reset
|
|
||||||
'ENETUNREACH', // Network unreachable
|
|
||||||
'EHOSTUNREACH', // Host unreachable
|
|
||||||
'EAI_AGAIN', // DNS temporary failure
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Classify an error and determine if it's network-related
|
|
||||||
* This is called ONLY when an error occurs (lazy validation)
|
|
||||||
*/
|
|
||||||
static async classifyError(error: Error, serviceName: string = 'API'): Promise<NetworkErrorResult> {
|
|
||||||
const errorMessage = error.message.toLowerCase();
|
|
||||||
const errorCode = (error as any).code?.toUpperCase();
|
|
||||||
|
|
||||||
// Check if it's a known network error
|
|
||||||
if (this.isNetworkErrorCode(errorCode) || this.containsNetworkKeywords(errorMessage)) {
|
|
||||||
// Perform a quick connectivity check to confirm
|
|
||||||
const isOnline = await this.quickConnectivityCheck();
|
|
||||||
|
|
||||||
if (!isOnline) {
|
|
||||||
return {
|
|
||||||
isNetworkError: true,
|
|
||||||
errorType: 'connection',
|
|
||||||
userMessage: `Network connection failed: Unable to reach ${serviceName}. Please check your internet connection.`,
|
|
||||||
technicalDetails: error.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Network is up, but specific error occurred
|
|
||||||
const specificError = this.getSpecificNetworkError(errorCode, errorMessage, serviceName);
|
|
||||||
return specificError;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not a network error - return original error
|
|
||||||
return {
|
|
||||||
isNetworkError: false,
|
|
||||||
userMessage: error.message,
|
|
||||||
technicalDetails: error.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Quick connectivity check - only called when an error occurs
|
|
||||||
* Tests DNS resolution to a reliable endpoint (Google DNS)
|
|
||||||
*/
|
|
||||||
private static async quickConnectivityCheck(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
// Use Google's public DNS as a reliable test endpoint
|
|
||||||
await Promise.race([
|
|
||||||
dnsLookup('dns.google'),
|
|
||||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000)),
|
|
||||||
]);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if error code is a known network error
|
|
||||||
*/
|
|
||||||
private static isNetworkErrorCode(code?: string): boolean {
|
|
||||||
if (!code) return false;
|
|
||||||
return this.NETWORK_ERROR_CODES.includes(code);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if error message contains network-related keywords
|
|
||||||
*/
|
|
||||||
private static containsNetworkKeywords(message: string): boolean {
|
|
||||||
const keywords = [
|
|
||||||
'fetch failed',
|
|
||||||
'network',
|
|
||||||
'connection',
|
|
||||||
'timeout',
|
|
||||||
'dns',
|
|
||||||
'unreachable',
|
|
||||||
'refused',
|
|
||||||
'reset',
|
|
||||||
'enotfound',
|
|
||||||
'etimedout',
|
|
||||||
'econnrefused',
|
|
||||||
'econnreset',
|
|
||||||
];
|
|
||||||
|
|
||||||
return keywords.some((keyword) => message.includes(keyword));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get specific error details based on error code/message
|
|
||||||
*/
|
|
||||||
private static getSpecificNetworkError(
|
|
||||||
code: string | undefined,
|
|
||||||
message: string,
|
|
||||||
serviceName: string,
|
|
||||||
): NetworkErrorResult {
|
|
||||||
// DNS resolution failures
|
|
||||||
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || message.includes('dns')) {
|
|
||||||
return {
|
|
||||||
isNetworkError: true,
|
|
||||||
errorType: 'dns',
|
|
||||||
userMessage: `DNS resolution failed: Unable to resolve ${serviceName} hostname. Check your DNS settings or internet connection.`,
|
|
||||||
technicalDetails: `Error code: ${code || 'unknown'}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connection timeout
|
|
||||||
if (code === 'ETIMEDOUT' || message.includes('timeout')) {
|
|
||||||
return {
|
|
||||||
isNetworkError: true,
|
|
||||||
errorType: 'timeout',
|
|
||||||
userMessage: `Connection timeout: ${serviceName} did not respond in time. This may be due to slow internet or firewall blocking.`,
|
|
||||||
technicalDetails: `Error code: ${code || 'timeout'}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connection refused
|
|
||||||
if (code === 'ECONNREFUSED' || message.includes('refused')) {
|
|
||||||
return {
|
|
||||||
isNetworkError: true,
|
|
||||||
errorType: 'refused',
|
|
||||||
userMessage: `Connection refused: ${serviceName} is not accepting connections. Service may be down or blocked by firewall.`,
|
|
||||||
technicalDetails: `Error code: ${code || 'refused'}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connection reset
|
|
||||||
if (code === 'ECONNRESET' || code === 'ENETUNREACH' || code === 'EHOSTUNREACH') {
|
|
||||||
return {
|
|
||||||
isNetworkError: true,
|
|
||||||
errorType: 'reset',
|
|
||||||
userMessage: `Connection lost: Network connection to ${serviceName} was interrupted. Check your internet stability.`,
|
|
||||||
technicalDetails: `Error code: ${code || 'reset'}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generic network error
|
|
||||||
return {
|
|
||||||
isNetworkError: true,
|
|
||||||
errorType: 'unknown',
|
|
||||||
userMessage: `Network error: Unable to connect to ${serviceName}. Please check your internet connection and firewall settings.`,
|
|
||||||
technicalDetails: message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format error for logging with both user-friendly and technical details
|
|
||||||
*/
|
|
||||||
static formatErrorForLogging(result: NetworkErrorResult): string {
|
|
||||||
if (result.isNetworkError) {
|
|
||||||
return `[NETWORK ERROR - ${result.errorType?.toUpperCase()}] ${result.userMessage}${
|
|
||||||
result.technicalDetails ? ` | Technical: ${result.technicalDetails}` : ''
|
|
||||||
}`;
|
|
||||||
}
|
|
||||||
return result.userMessage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
/**
|
|
||||||
* Manual test script for NetworkErrorDetector
|
|
||||||
* Run with: npx tsx test-network-error-detector.ts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NetworkErrorDetector } from './src/utils/NetworkErrorDetector';
|
|
||||||
|
|
||||||
async function testNetworkErrorDetector() {
|
|
||||||
console.log('🧪 Testing NetworkErrorDetector\n');
|
|
||||||
|
|
||||||
// Test 1: DNS Error
|
|
||||||
console.log('Test 1: DNS Resolution Error (ENOTFOUND)');
|
|
||||||
const dnsError = new Error('getaddrinfo ENOTFOUND api.example.com');
|
|
||||||
(dnsError as any).code = 'ENOTFOUND';
|
|
||||||
const dnsResult = await NetworkErrorDetector.classifyError(dnsError, 'Gemini API');
|
|
||||||
console.log(' Is Network Error:', dnsResult.isNetworkError);
|
|
||||||
console.log(' Error Type:', dnsResult.errorType);
|
|
||||||
console.log(' User Message:', dnsResult.userMessage);
|
|
||||||
console.log(' Formatted Log:', NetworkErrorDetector.formatErrorForLogging(dnsResult));
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
// Test 2: Timeout Error
|
|
||||||
console.log('Test 2: Connection Timeout (ETIMEDOUT)');
|
|
||||||
const timeoutError = new Error('connect ETIMEDOUT 142.250.185.10:443');
|
|
||||||
(timeoutError as any).code = 'ETIMEDOUT';
|
|
||||||
const timeoutResult = await NetworkErrorDetector.classifyError(timeoutError, 'Gemini API');
|
|
||||||
console.log(' Is Network Error:', timeoutResult.isNetworkError);
|
|
||||||
console.log(' Error Type:', timeoutResult.errorType);
|
|
||||||
console.log(' User Message:', timeoutResult.userMessage);
|
|
||||||
console.log(' Formatted Log:', NetworkErrorDetector.formatErrorForLogging(timeoutResult));
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
// Test 3: Fetch Failed (actual error from logs)
|
|
||||||
console.log('Test 3: Fetch Failed Error (from your logs)');
|
|
||||||
const fetchError = new Error('exception TypeError: fetch failed sending request');
|
|
||||||
const fetchResult = await NetworkErrorDetector.classifyError(fetchError, 'Gemini API');
|
|
||||||
console.log(' Is Network Error:', fetchResult.isNetworkError);
|
|
||||||
console.log(' Error Type:', fetchResult.errorType);
|
|
||||||
console.log(' User Message:', fetchResult.userMessage);
|
|
||||||
console.log(' Formatted Log:', NetworkErrorDetector.formatErrorForLogging(fetchResult));
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
// Test 4: Non-network Error
|
|
||||||
console.log('Test 4: Non-Network Error (Invalid API Key)');
|
|
||||||
const apiError = new Error('Invalid API key provided');
|
|
||||||
const apiResult = await NetworkErrorDetector.classifyError(apiError, 'Gemini API');
|
|
||||||
console.log(' Is Network Error:', apiResult.isNetworkError);
|
|
||||||
console.log(' User Message:', apiResult.userMessage);
|
|
||||||
console.log(' Formatted Log:', NetworkErrorDetector.formatErrorForLogging(apiResult));
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
console.log('✅ Tests completed!');
|
|
||||||
console.log('\n📝 Summary:');
|
|
||||||
console.log(' - Network errors are detected and classified');
|
|
||||||
console.log(' - User-friendly messages are provided');
|
|
||||||
console.log(' - Technical details are preserved for logging');
|
|
||||||
console.log(' - No overhead on successful requests (only runs on failures)');
|
|
||||||
}
|
|
||||||
|
|
||||||
testNetworkErrorDetector().catch(console.error);
|
|
||||||
|
|
@ -35,9 +35,15 @@
|
||||||
"@/utils/*": ["src/utils/*"]
|
"@/utils/*": ["src/utils/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": [
|
||||||
"exclude": ["node_modules", "dist", "tests/**/*"],
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"tests/**/*"
|
||||||
|
],
|
||||||
"ts-node": {
|
"ts-node": {
|
||||||
"esm": true
|
"esm": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
{
|
|
||||||
"extends": ["next/core-web-vitals", "prettier"],
|
|
||||||
"plugins": ["prettier"],
|
|
||||||
"rules": {
|
|
||||||
"prettier/prettier": "error"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,13 +7,11 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
||||||
## Components Architecture
|
## Components Architecture
|
||||||
|
|
||||||
### 1. MinimizedApiKey Component
|
### 1. MinimizedApiKey Component
|
||||||
|
|
||||||
**Location:** `src/components/demo/MinimizedApiKey.tsx`
|
**Location:** `src/components/demo/MinimizedApiKey.tsx`
|
||||||
|
|
||||||
**Purpose:** Minimizes API key section to a badge after validation, freeing up valuable screen space.
|
**Purpose:** Minimizes API key section to a badge after validation, freeing up valuable screen space.
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|
||||||
- Fixed position in top-right corner (z-index: 40)
|
- Fixed position in top-right corner (z-index: 40)
|
||||||
- Collapsed state: Shows `org/project` slugs with green status indicator
|
- Collapsed state: Shows `org/project` slugs with green status indicator
|
||||||
- Expanded state: Full card with API key visibility toggle and revoke button
|
- Expanded state: Full card with API key visibility toggle and revoke button
|
||||||
|
|
@ -22,13 +20,11 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
||||||
- ARIA labels for screen readers
|
- ARIA labels for screen readers
|
||||||
|
|
||||||
**Design Patterns:**
|
**Design Patterns:**
|
||||||
|
|
||||||
- Badge: `px-4 py-2 bg-slate-900/95 backdrop-blur-sm border border-slate-700 rounded-full`
|
- Badge: `px-4 py-2 bg-slate-900/95 backdrop-blur-sm border border-slate-700 rounded-full`
|
||||||
- Green indicator: `w-2 h-2 rounded-full bg-green-400`
|
- Green indicator: `w-2 h-2 rounded-full bg-green-400`
|
||||||
- Hover states with amber accent
|
- Hover states with amber accent
|
||||||
|
|
||||||
**Accessibility:**
|
**Accessibility:**
|
||||||
|
|
||||||
- `aria-label` on all buttons
|
- `aria-label` on all buttons
|
||||||
- Focus ring on interactions: `focus:ring-2 focus:ring-amber-500`
|
- Focus ring on interactions: `focus:ring-2 focus:ring-amber-500`
|
||||||
- Keyboard navigation support
|
- Keyboard navigation support
|
||||||
|
|
@ -37,13 +33,11 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2. PromptReuseButton Component
|
### 2. PromptReuseButton Component
|
||||||
|
|
||||||
**Location:** `src/components/demo/PromptReuseButton.tsx`
|
**Location:** `src/components/demo/PromptReuseButton.tsx`
|
||||||
|
|
||||||
**Purpose:** Allows users to quickly reuse prompts from previous generations.
|
**Purpose:** Allows users to quickly reuse prompts from previous generations.
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|
||||||
- Small, compact button next to prompt text
|
- Small, compact button next to prompt text
|
||||||
- Visual feedback on click (changes to "Inserted" state)
|
- Visual feedback on click (changes to "Inserted" state)
|
||||||
- Auto-resets after 1 second
|
- Auto-resets after 1 second
|
||||||
|
|
@ -51,14 +45,12 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
||||||
- Icon + text label for clarity
|
- Icon + text label for clarity
|
||||||
|
|
||||||
**Design Patterns:**
|
**Design Patterns:**
|
||||||
|
|
||||||
- Compact size: `px-2 py-1 text-xs`
|
- Compact size: `px-2 py-1 text-xs`
|
||||||
- Slate background with amber hover: `bg-slate-800/50 hover:bg-amber-600/20`
|
- Slate background with amber hover: `bg-slate-800/50 hover:bg-amber-600/20`
|
||||||
- Border transition: `border-slate-700 hover:border-amber-600/50`
|
- Border transition: `border-slate-700 hover:border-amber-600/50`
|
||||||
- Refresh icon (↻) for "reuse" action
|
- Refresh icon (↻) for "reuse" action
|
||||||
|
|
||||||
**Accessibility:**
|
**Accessibility:**
|
||||||
|
|
||||||
- Descriptive `aria-label` with context
|
- Descriptive `aria-label` with context
|
||||||
- Title attribute for tooltip
|
- Title attribute for tooltip
|
||||||
- Focus indicator
|
- Focus indicator
|
||||||
|
|
@ -67,18 +59,15 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. GenerationTimer Component
|
### 3. GenerationTimer Component
|
||||||
|
|
||||||
**Location:** `src/components/demo/GenerationTimer.tsx`
|
**Location:** `src/components/demo/GenerationTimer.tsx`
|
||||||
|
|
||||||
**Purpose:** Shows live generation time during API calls and final duration on results.
|
**Purpose:** Shows live generation time during API calls and final duration on results.
|
||||||
|
|
||||||
**Components:**
|
**Components:**
|
||||||
|
|
||||||
- `GenerationTimer`: Live timer during generation (updates every 100ms)
|
- `GenerationTimer`: Live timer during generation (updates every 100ms)
|
||||||
- `CompletedTimerBadge`: Static badge showing final duration
|
- `CompletedTimerBadge`: Static badge showing final duration
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|
||||||
- Live updates during generation with spinning icon
|
- Live updates during generation with spinning icon
|
||||||
- Format: "⏱️ 2.3s"
|
- Format: "⏱️ 2.3s"
|
||||||
- Two variants: `inline` (with spinner) and `badge` (compact)
|
- Two variants: `inline` (with spinner) and `badge` (compact)
|
||||||
|
|
@ -86,14 +75,12 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
||||||
- Green badge for completed generations
|
- Green badge for completed generations
|
||||||
|
|
||||||
**Design Patterns:**
|
**Design Patterns:**
|
||||||
|
|
||||||
- Inline: `text-sm text-gray-400` with amber clock icon
|
- Inline: `text-sm text-gray-400` with amber clock icon
|
||||||
- Badge: `bg-slate-900/80 border border-slate-700 rounded-md`
|
- Badge: `bg-slate-900/80 border border-slate-700 rounded-md`
|
||||||
- Completed: `bg-green-900/20 border border-green-700/50 text-green-400`
|
- Completed: `bg-green-900/20 border border-green-700/50 text-green-400`
|
||||||
- Spinning animation on clock icon during generation
|
- Spinning animation on clock icon during generation
|
||||||
|
|
||||||
**Accessibility:**
|
**Accessibility:**
|
||||||
|
|
||||||
- Live region for screen readers (implicit via state updates)
|
- Live region for screen readers (implicit via state updates)
|
||||||
- Clear visual distinction between active/completed states
|
- Clear visual distinction between active/completed states
|
||||||
- Sufficient color contrast (WCAG AA compliant)
|
- Sufficient color contrast (WCAG AA compliant)
|
||||||
|
|
@ -101,13 +88,11 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
||||||
---
|
---
|
||||||
|
|
||||||
### 4. InspectMode Component
|
### 4. InspectMode Component
|
||||||
|
|
||||||
**Location:** `src/components/demo/InspectMode.tsx`
|
**Location:** `src/components/demo/InspectMode.tsx`
|
||||||
|
|
||||||
**Purpose:** Developer tool to inspect raw API request/response data and Gemini parameters.
|
**Purpose:** Developer tool to inspect raw API request/response data and Gemini parameters.
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|
||||||
- Two-column layout (left: original, right: enhanced)
|
- Two-column layout (left: original, right: enhanced)
|
||||||
- Three collapsible sections per column:
|
- Three collapsible sections per column:
|
||||||
- API Request
|
- API Request
|
||||||
|
|
@ -119,7 +104,6 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
||||||
- Max height with scroll for long data
|
- Max height with scroll for long data
|
||||||
|
|
||||||
**Design Patterns:**
|
**Design Patterns:**
|
||||||
|
|
||||||
- Grid layout: `grid md:grid-cols-2 gap-4`
|
- Grid layout: `grid md:grid-cols-2 gap-4`
|
||||||
- Collapsible headers: `bg-slate-900/50 hover:bg-slate-900/70`
|
- Collapsible headers: `bg-slate-900/50 hover:bg-slate-900/70`
|
||||||
- JSON container: `bg-slate-950/50 border border-slate-700 rounded-lg`
|
- JSON container: `bg-slate-950/50 border border-slate-700 rounded-lg`
|
||||||
|
|
@ -130,7 +114,6 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
||||||
- Booleans/null: `text-purple-400`
|
- Booleans/null: `text-purple-400`
|
||||||
|
|
||||||
**Accessibility:**
|
**Accessibility:**
|
||||||
|
|
||||||
- `aria-expanded` on collapsible buttons
|
- `aria-expanded` on collapsible buttons
|
||||||
- Descriptive `aria-label` for each section
|
- Descriptive `aria-label` for each section
|
||||||
- Keyboard navigation (Enter/Space to toggle)
|
- Keyboard navigation (Enter/Space to toggle)
|
||||||
|
|
@ -138,7 +121,6 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
||||||
- Scrollable with overflow for long content
|
- Scrollable with overflow for long content
|
||||||
|
|
||||||
**Technical Details:**
|
**Technical Details:**
|
||||||
|
|
||||||
- JSON escaping for safe HTML rendering
|
- JSON escaping for safe HTML rendering
|
||||||
- `dangerouslySetInnerHTML` used ONLY for pre-sanitized content
|
- `dangerouslySetInnerHTML` used ONLY for pre-sanitized content
|
||||||
- Each section independently collapsible
|
- Each section independently collapsible
|
||||||
|
|
@ -147,13 +129,11 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5. ResultCard Component
|
### 5. ResultCard Component
|
||||||
|
|
||||||
**Location:** `src/components/demo/ResultCard.tsx`
|
**Location:** `src/components/demo/ResultCard.tsx`
|
||||||
|
|
||||||
**Purpose:** Enhanced result display with preview/inspect modes and code examples.
|
**Purpose:** Enhanced result display with preview/inspect modes and code examples.
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|
||||||
- **View Mode Toggle:** Switch between Preview (images) and Inspect (data)
|
- **View Mode Toggle:** Switch between Preview (images) and Inspect (data)
|
||||||
- **Image Preview Mode:**
|
- **Image Preview Mode:**
|
||||||
- Side-by-side image comparison (horizontal scroll)
|
- Side-by-side image comparison (horizontal scroll)
|
||||||
|
|
@ -171,7 +151,6 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
||||||
- Terminal-style UI with traffic light dots
|
- Terminal-style UI with traffic light dots
|
||||||
|
|
||||||
**Design Patterns:**
|
**Design Patterns:**
|
||||||
|
|
||||||
- Card: `p-5 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl`
|
- Card: `p-5 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl`
|
||||||
- Mode toggle: `bg-slate-950/50 border border-slate-700 rounded-lg`
|
- Mode toggle: `bg-slate-950/50 border border-slate-700 rounded-lg`
|
||||||
- Active tab: `bg-amber-600 text-white`
|
- Active tab: `bg-amber-600 text-white`
|
||||||
|
|
@ -179,7 +158,6 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
||||||
- Code block: Terminal UI with red/yellow/green dots
|
- Code block: Terminal UI with red/yellow/green dots
|
||||||
|
|
||||||
**Accessibility:**
|
**Accessibility:**
|
||||||
|
|
||||||
- `aria-pressed` on mode toggle buttons
|
- `aria-pressed` on mode toggle buttons
|
||||||
- Semantic HTML for tab structure
|
- Semantic HTML for tab structure
|
||||||
- Keyboard navigation (Tab, Arrow keys)
|
- Keyboard navigation (Tab, Arrow keys)
|
||||||
|
|
@ -188,7 +166,6 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
||||||
- Download button with descriptive label
|
- Download button with descriptive label
|
||||||
|
|
||||||
**Responsive Behavior:**
|
**Responsive Behavior:**
|
||||||
|
|
||||||
- Mobile (< 768px):
|
- Mobile (< 768px):
|
||||||
- Single column layout
|
- Single column layout
|
||||||
- Horizontal scroll for images
|
- Horizontal scroll for images
|
||||||
|
|
@ -201,7 +178,6 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
||||||
- Full layout with optimal spacing
|
- Full layout with optimal spacing
|
||||||
|
|
||||||
**Code Examples - REST Format:**
|
**Code Examples - REST Format:**
|
||||||
|
|
||||||
```
|
```
|
||||||
### Generate Image - Text to Image
|
### Generate Image - Text to Image
|
||||||
POST http://localhost:3000/api/text-to-image
|
POST http://localhost:3000/api/text-to-image
|
||||||
|
|
@ -217,7 +193,6 @@ X-API-Key: your-api-key
|
||||||
---
|
---
|
||||||
|
|
||||||
## Main Page Refactoring
|
## Main Page Refactoring
|
||||||
|
|
||||||
**Location:** `src/app/demo/tti/page.tsx`
|
**Location:** `src/app/demo/tti/page.tsx`
|
||||||
|
|
||||||
### Changes Made
|
### Changes Made
|
||||||
|
|
@ -257,7 +232,6 @@ X-API-Key: your-api-key
|
||||||
All components strictly follow the Banatie design system:
|
All components strictly follow the Banatie design system:
|
||||||
|
|
||||||
### Colors
|
### Colors
|
||||||
|
|
||||||
- Backgrounds: `bg-slate-950`, `bg-slate-900/80`, `bg-slate-800`
|
- Backgrounds: `bg-slate-950`, `bg-slate-900/80`, `bg-slate-800`
|
||||||
- Gradients: `from-amber-600 to-orange-600`
|
- Gradients: `from-amber-600 to-orange-600`
|
||||||
- Text: `text-white`, `text-gray-300`, `text-gray-400`, `text-gray-500`
|
- Text: `text-white`, `text-gray-300`, `text-gray-400`, `text-gray-500`
|
||||||
|
|
@ -265,28 +239,24 @@ All components strictly follow the Banatie design system:
|
||||||
- Accents: Amber for primary actions, green for success, red for errors
|
- Accents: Amber for primary actions, green for success, red for errors
|
||||||
|
|
||||||
### Typography
|
### Typography
|
||||||
|
|
||||||
- Headings: `text-3xl md:text-4xl lg:text-5xl font-bold text-white`
|
- Headings: `text-3xl md:text-4xl lg:text-5xl font-bold text-white`
|
||||||
- Subheadings: `text-lg font-semibold text-white`
|
- Subheadings: `text-lg font-semibold text-white`
|
||||||
- Body: `text-sm text-gray-300`
|
- Body: `text-sm text-gray-300`
|
||||||
- Small: `text-xs text-gray-400`
|
- Small: `text-xs text-gray-400`
|
||||||
|
|
||||||
### Spacing
|
### Spacing
|
||||||
|
|
||||||
- Container: `max-w-7xl mx-auto px-6`
|
- Container: `max-w-7xl mx-auto px-6`
|
||||||
- Card padding: `p-5` (compact) or `p-6` (standard)
|
- Card padding: `p-5` (compact) or `p-6` (standard)
|
||||||
- Section gaps: `space-y-6` or `space-y-8`
|
- Section gaps: `space-y-6` or `space-y-8`
|
||||||
- Button padding: `px-6 py-2.5` (compact), `px-8 py-3` (standard)
|
- Button padding: `px-6 py-2.5` (compact), `px-8 py-3` (standard)
|
||||||
|
|
||||||
### Rounded Corners
|
### Rounded Corners
|
||||||
|
|
||||||
- Cards: `rounded-2xl`
|
- Cards: `rounded-2xl`
|
||||||
- Buttons: `rounded-lg`
|
- Buttons: `rounded-lg`
|
||||||
- Inputs: `rounded-lg`
|
- Inputs: `rounded-lg`
|
||||||
- Badges: `rounded-full` (minimized API key), `rounded-md` (small badges)
|
- Badges: `rounded-full` (minimized API key), `rounded-md` (small badges)
|
||||||
|
|
||||||
### Transitions
|
### Transitions
|
||||||
|
|
||||||
- All interactive elements: `transition-all` or `transition-colors`
|
- All interactive elements: `transition-all` or `transition-colors`
|
||||||
- Hover states smooth and predictable
|
- Hover states smooth and predictable
|
||||||
- Animations: `animate-fade-in` (0.5s ease-out)
|
- Animations: `animate-fade-in` (0.5s ease-out)
|
||||||
|
|
@ -296,13 +266,11 @@ All components strictly follow the Banatie design system:
|
||||||
## Accessibility Compliance (WCAG 2.1 AA)
|
## Accessibility Compliance (WCAG 2.1 AA)
|
||||||
|
|
||||||
### Semantic HTML
|
### Semantic HTML
|
||||||
|
|
||||||
- Proper heading hierarchy: h1 → h2 (no skipped levels)
|
- Proper heading hierarchy: h1 → h2 (no skipped levels)
|
||||||
- Landmark regions: `<header>`, `<section>`, `<main>` (implicit)
|
- Landmark regions: `<header>`, `<section>`, `<main>` (implicit)
|
||||||
- Form labels properly associated
|
- Form labels properly associated
|
||||||
|
|
||||||
### Keyboard Navigation
|
### Keyboard Navigation
|
||||||
|
|
||||||
- All interactive elements keyboard accessible
|
- All interactive elements keyboard accessible
|
||||||
- Tab order logical and sequential
|
- Tab order logical and sequential
|
||||||
- Focus indicators visible on all focusable elements
|
- Focus indicators visible on all focusable elements
|
||||||
|
|
@ -310,13 +278,11 @@ All components strictly follow the Banatie design system:
|
||||||
- Enter key validation support
|
- Enter key validation support
|
||||||
|
|
||||||
### Color Contrast
|
### Color Contrast
|
||||||
|
|
||||||
- Text on backgrounds: Minimum 4.5:1 (tested with Banatie colors)
|
- Text on backgrounds: Minimum 4.5:1 (tested with Banatie colors)
|
||||||
- Interactive elements clearly distinguishable
|
- Interactive elements clearly distinguishable
|
||||||
- Disabled states visible but distinct
|
- Disabled states visible but distinct
|
||||||
|
|
||||||
### ARIA Attributes
|
### ARIA Attributes
|
||||||
|
|
||||||
- `aria-label` on icon-only buttons
|
- `aria-label` on icon-only buttons
|
||||||
- `aria-pressed` on toggle buttons
|
- `aria-pressed` on toggle buttons
|
||||||
- `aria-expanded` on collapsible sections
|
- `aria-expanded` on collapsible sections
|
||||||
|
|
@ -325,7 +291,6 @@ All components strictly follow the Banatie design system:
|
||||||
- `aria-label` on sections for screen reader context
|
- `aria-label` on sections for screen reader context
|
||||||
|
|
||||||
### Screen Reader Support
|
### Screen Reader Support
|
||||||
|
|
||||||
- Meaningful alt text on images
|
- Meaningful alt text on images
|
||||||
- Button labels descriptive ("Close zoomed image" not just "Close")
|
- Button labels descriptive ("Close zoomed image" not just "Close")
|
||||||
- State changes announced (via ARIA live regions)
|
- State changes announced (via ARIA live regions)
|
||||||
|
|
@ -355,7 +320,6 @@ All components strictly follow the Banatie design system:
|
||||||
## Responsive Breakpoints
|
## Responsive Breakpoints
|
||||||
|
|
||||||
### Mobile (< 768px)
|
### Mobile (< 768px)
|
||||||
|
|
||||||
- Single column layouts
|
- Single column layouts
|
||||||
- Stacked buttons with wrapping
|
- Stacked buttons with wrapping
|
||||||
- Horizontal scroll for image comparison
|
- Horizontal scroll for image comparison
|
||||||
|
|
@ -363,20 +327,17 @@ All components strictly follow the Banatie design system:
|
||||||
- Text sizes: base, sm, xs
|
- Text sizes: base, sm, xs
|
||||||
|
|
||||||
### Tablet (>= 768px, md:)
|
### Tablet (>= 768px, md:)
|
||||||
|
|
||||||
- Two-column inspect mode
|
- Two-column inspect mode
|
||||||
- Side-by-side images maintained
|
- Side-by-side images maintained
|
||||||
- Increased padding
|
- Increased padding
|
||||||
- Text sizes: md scale up
|
- Text sizes: md scale up
|
||||||
|
|
||||||
### Desktop (>= 1024px, lg:)
|
### Desktop (>= 1024px, lg:)
|
||||||
|
|
||||||
- Optimal spacing
|
- Optimal spacing
|
||||||
- Full feature display
|
- Full feature display
|
||||||
- Larger text sizes
|
- Larger text sizes
|
||||||
|
|
||||||
### XL (>= 1280px, xl:)
|
### XL (>= 1280px, xl:)
|
||||||
|
|
||||||
- Max width container constrains growth
|
- Max width container constrains growth
|
||||||
- Centered content
|
- Centered content
|
||||||
|
|
||||||
|
|
@ -405,7 +366,6 @@ apps/landing/src/
|
||||||
## Usage Examples
|
## Usage Examples
|
||||||
|
|
||||||
### Reusing a Prompt
|
### Reusing a Prompt
|
||||||
|
|
||||||
1. Generate images
|
1. Generate images
|
||||||
2. Find the prompt you want to reuse
|
2. Find the prompt you want to reuse
|
||||||
3. Click "Reuse" button next to the prompt
|
3. Click "Reuse" button next to the prompt
|
||||||
|
|
@ -413,7 +373,6 @@ apps/landing/src/
|
||||||
5. Focus shifts to textarea for editing
|
5. Focus shifts to textarea for editing
|
||||||
|
|
||||||
### Inspecting API Data
|
### Inspecting API Data
|
||||||
|
|
||||||
1. Generate images
|
1. Generate images
|
||||||
2. Click "Inspect" mode toggle in result card
|
2. Click "Inspect" mode toggle in result card
|
||||||
3. View request/response data in two columns
|
3. View request/response data in two columns
|
||||||
|
|
@ -421,7 +380,6 @@ apps/landing/src/
|
||||||
5. Copy JSON with copy buttons
|
5. Copy JSON with copy buttons
|
||||||
|
|
||||||
### Using REST Code Example
|
### Using REST Code Example
|
||||||
|
|
||||||
1. Generate images
|
1. Generate images
|
||||||
2. Navigate to code examples section
|
2. Navigate to code examples section
|
||||||
3. Click "REST" tab
|
3. Click "REST" tab
|
||||||
|
|
@ -450,14 +408,12 @@ apps/landing/src/
|
||||||
## Browser Compatibility
|
## Browser Compatibility
|
||||||
|
|
||||||
Tested and designed for:
|
Tested and designed for:
|
||||||
|
|
||||||
- Chrome/Edge (Chromium)
|
- Chrome/Edge (Chromium)
|
||||||
- Firefox
|
- Firefox
|
||||||
- Safari
|
- Safari
|
||||||
- Mobile browsers (iOS Safari, Chrome Android)
|
- Mobile browsers (iOS Safari, Chrome Android)
|
||||||
|
|
||||||
Uses standard web APIs:
|
Uses standard web APIs:
|
||||||
|
|
||||||
- Clipboard API (navigator.clipboard)
|
- Clipboard API (navigator.clipboard)
|
||||||
- CSS Grid and Flexbox
|
- CSS Grid and Flexbox
|
||||||
- CSS Custom Properties
|
- CSS Custom Properties
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { NextConfig } from 'next';
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'export',
|
output: 'export',
|
||||||
|
|
|
||||||
|
|
@ -87,8 +87,7 @@ export default function ApiKeysPage() {
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-4xl font-bold text-white mb-2">Project API Keys</h1>
|
<h1 className="text-4xl font-bold text-white mb-2">Project API Keys</h1>
|
||||||
<p className="text-slate-400">
|
<p className="text-slate-400">
|
||||||
Generate API keys for your projects. Organizations and projects will be created
|
Generate API keys for your projects. Organizations and projects will be created automatically if they don't exist.
|
||||||
automatically if they don't exist.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -161,13 +160,9 @@ export default function ApiKeysPage() {
|
||||||
{apiKeys.map((key) => (
|
{apiKeys.map((key) => (
|
||||||
<tr key={key.id} className="border-b border-slate-800">
|
<tr key={key.id} className="border-b border-slate-800">
|
||||||
<td className="py-3 text-sm text-slate-300">
|
<td className="py-3 text-sm text-slate-300">
|
||||||
<span
|
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
key.keyType === 'master' ? 'bg-amber-900/30 text-amber-400' : 'bg-blue-900/30 text-blue-400'
|
||||||
key.keyType === 'master'
|
}`}>
|
||||||
? 'bg-amber-900/30 text-amber-400'
|
|
||||||
: 'bg-blue-900/30 text-blue-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{key.keyType}
|
{key.keyType}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -185,13 +180,9 @@ export default function ApiKeysPage() {
|
||||||
{key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : 'Never'}
|
{key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : 'Never'}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 text-sm">
|
<td className="py-3 text-sm">
|
||||||
<span
|
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
key.isActive ? 'bg-green-900/30 text-green-400' : 'bg-red-900/30 text-red-400'
|
||||||
key.isActive
|
}`}>
|
||||||
? 'bg-green-900/30 text-green-400'
|
|
||||||
: 'bg-red-900/30 text-red-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{key.isActive ? 'Active' : 'Inactive'}
|
{key.isActive ? 'Active' : 'Inactive'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -88,8 +88,7 @@ export default function MasterKeyPage() {
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-4xl font-bold text-white mb-2">Master Key Management</h1>
|
<h1 className="text-4xl font-bold text-white mb-2">Master Key Management</h1>
|
||||||
<p className="text-slate-400">
|
<p className="text-slate-400">
|
||||||
Bootstrap your master key or manually configure it. This key is required to generate
|
Bootstrap your master key or manually configure it. This key is required to generate project API keys.
|
||||||
project API keys.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -229,8 +229,8 @@ export default function DemoTTIPage() {
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
autoEnhance: false, // Explicitly disable enhancement for left image
|
autoEnhance: false, // Explicitly disable enhancement for left image
|
||||||
meta: {
|
meta: {
|
||||||
tags: [pairId, 'simple'], // NEW: Pair ID + "simple" tag
|
tags: [pairId, 'simple'] // NEW: Pair ID + "simple" tag
|
||||||
},
|
}
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
fetch(`${API_BASE_URL}/api/text-to-image`, {
|
fetch(`${API_BASE_URL}/api/text-to-image`, {
|
||||||
|
|
@ -248,8 +248,8 @@ export default function DemoTTIPage() {
|
||||||
template: template || 'photorealistic', // Only template parameter
|
template: template || 'photorealistic', // Only template parameter
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
tags: [pairId, 'enhanced'], // NEW: Pair ID + "enhanced" tag
|
tags: [pairId, 'enhanced'] // NEW: Pair ID + "enhanced" tag
|
||||||
},
|
}
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
@ -289,8 +289,8 @@ export default function DemoTTIPage() {
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
autoEnhance: false,
|
autoEnhance: false,
|
||||||
meta: {
|
meta: {
|
||||||
tags: [pairId, 'simple'],
|
tags: [pairId, 'simple']
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
response: leftData,
|
response: leftData,
|
||||||
geminiParams: leftData.data?.geminiParams || {},
|
geminiParams: leftData.data?.geminiParams || {},
|
||||||
|
|
@ -305,8 +305,8 @@ export default function DemoTTIPage() {
|
||||||
template: template || 'photorealistic',
|
template: template || 'photorealistic',
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
tags: [pairId, 'enhanced'],
|
tags: [pairId, 'enhanced']
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
response: rightData,
|
response: rightData,
|
||||||
geminiParams: rightData.data?.geminiParams || {},
|
geminiParams: rightData.data?.geminiParams || {},
|
||||||
|
|
@ -315,8 +315,8 @@ export default function DemoTTIPage() {
|
||||||
enhancementOptions: {
|
enhancementOptions: {
|
||||||
template,
|
template,
|
||||||
meta: {
|
meta: {
|
||||||
tags: [pairId, 'enhanced'],
|
tags: [pairId, 'enhanced']
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -334,7 +334,9 @@ export default function DemoTTIPage() {
|
||||||
// Clear prompt
|
// Clear prompt
|
||||||
setPrompt('');
|
setPrompt('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setGenerationError(error instanceof Error ? error.message : 'Failed to generate images');
|
setGenerationError(
|
||||||
|
error instanceof Error ? error.message : 'Failed to generate images'
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setGenerating(false);
|
setGenerating(false);
|
||||||
setGenerationStartTime(undefined);
|
setGenerationStartTime(undefined);
|
||||||
|
|
@ -402,10 +404,7 @@ export default function DemoTTIPage() {
|
||||||
|
|
||||||
{/* API Key Section - Only show when not validated */}
|
{/* API Key Section - Only show when not validated */}
|
||||||
{!apiKeyValidated && (
|
{!apiKeyValidated && (
|
||||||
<section
|
<section className="mb-6 p-5 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl" aria-label="API Key Validation">
|
||||||
className="mb-6 p-5 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl"
|
|
||||||
aria-label="API Key Validation"
|
|
||||||
>
|
|
||||||
<h2 className="text-lg font-semibold text-white mb-3">API Key</h2>
|
<h2 className="text-lg font-semibold text-white mb-3">API Key</h2>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
|
|
@ -431,27 +430,12 @@ export default function DemoTTIPage() {
|
||||||
>
|
>
|
||||||
{apiKeyVisible ? (
|
{apiKeyVisible ? (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
strokeLinecap="round"
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -474,10 +458,7 @@ export default function DemoTTIPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Unified Prompt & Generation Card */}
|
{/* Unified Prompt & Generation Card */}
|
||||||
<section
|
<section className="mb-8 p-6 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl" aria-label="Image Generation">
|
||||||
className="mb-8 p-6 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl"
|
|
||||||
aria-label="Image Generation"
|
|
||||||
>
|
|
||||||
{/* Prompt Textarea */}
|
{/* Prompt Textarea */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label htmlFor="prompt-input" className="block text-lg font-semibold text-white mb-3">
|
<label htmlFor="prompt-input" className="block text-lg font-semibold text-white mb-3">
|
||||||
|
|
@ -501,10 +482,7 @@ export default function DemoTTIPage() {
|
||||||
<div className="mb-4 flex flex-col md:flex-row gap-3 items-start md:items-end">
|
<div className="mb-4 flex flex-col md:flex-row gap-3 items-start md:items-end">
|
||||||
{/* Aspect Ratio */}
|
{/* Aspect Ratio */}
|
||||||
<div className="flex-1 min-w-[150px]">
|
<div className="flex-1 min-w-[150px]">
|
||||||
<label
|
<label htmlFor="aspect-ratio" className="block text-xs font-medium text-gray-400 mb-1.5">
|
||||||
htmlFor="aspect-ratio"
|
|
||||||
className="block text-xs font-medium text-gray-400 mb-1.5"
|
|
||||||
>
|
|
||||||
Aspect Ratio
|
Aspect Ratio
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
@import 'tailwindcss';
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #0f172a;
|
--background: #0f172a;
|
||||||
|
|
@ -15,18 +15,12 @@
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family:
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
'Inter',
|
|
||||||
-apple-system,
|
|
||||||
BlinkMacSystemFont,
|
|
||||||
'Segoe UI',
|
|
||||||
sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom animations */
|
/* Custom animations */
|
||||||
@keyframes gradient-shift {
|
@keyframes gradient-shift {
|
||||||
0%,
|
0%, 100% {
|
||||||
100% {
|
|
||||||
background-position: 0% 50%;
|
background-position: 0% 50%;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,35 @@
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from "next";
|
||||||
import { Inter } from 'next/font/google';
|
import { Inter } from "next/font/google";
|
||||||
import Image from 'next/image';
|
import Image from "next/image";
|
||||||
import './globals.css';
|
import "./globals.css";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
variable: '--font-inter',
|
variable: "--font-inter",
|
||||||
subsets: ['latin'],
|
subsets: ["latin"],
|
||||||
display: 'swap',
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Banatie - AI Image Generation API | Developer-First Platform',
|
title: "Banatie - AI Image Generation API | Developer-First Platform",
|
||||||
description:
|
description: "Transform text and reference images into production-ready visuals with Banatie's developer-first AI image generation API. Powered by Google Gemini & Imagen 4.0. Join the beta.",
|
||||||
"Transform text and reference images into production-ready visuals with Banatie's developer-first AI image generation API. Powered by Google Gemini & Imagen 4.0. Join the beta.",
|
keywords: ["AI image generation", "image generation API", "text to image", "Gemini API", "developer tools", "REST API", "image AI"],
|
||||||
keywords: [
|
authors: [{ name: "Banatie Team" }],
|
||||||
'AI image generation',
|
creator: "Banatie",
|
||||||
'image generation API',
|
publisher: "Banatie",
|
||||||
'text to image',
|
metadataBase: new URL("https://banatie.com"),
|
||||||
'Gemini API',
|
|
||||||
'developer tools',
|
|
||||||
'REST API',
|
|
||||||
'image AI',
|
|
||||||
],
|
|
||||||
authors: [{ name: 'Banatie Team' }],
|
|
||||||
creator: 'Banatie',
|
|
||||||
publisher: 'Banatie',
|
|
||||||
metadataBase: new URL('https://banatie.com'),
|
|
||||||
openGraph: {
|
openGraph: {
|
||||||
type: 'website',
|
type: "website",
|
||||||
locale: 'en_US',
|
locale: "en_US",
|
||||||
url: 'https://banatie.com',
|
url: "https://banatie.com",
|
||||||
title: 'Banatie - AI Image Generation API for Developers',
|
title: "Banatie - AI Image Generation API for Developers",
|
||||||
description:
|
description: "Developer-first API for AI-powered image generation. Transform text and reference images into production-ready visuals in seconds.",
|
||||||
'Developer-first API for AI-powered image generation. Transform text and reference images into production-ready visuals in seconds.',
|
siteName: "Banatie",
|
||||||
siteName: 'Banatie',
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: "summary_large_image",
|
||||||
title: 'Banatie - AI Image Generation API',
|
title: "Banatie - AI Image Generation API",
|
||||||
description: 'Developer-first API for AI-powered image generation. Join the beta.',
|
description: "Developer-first API for AI-powered image generation. Join the beta.",
|
||||||
creator: '@banatie',
|
creator: "@banatie",
|
||||||
},
|
},
|
||||||
robots: {
|
robots: {
|
||||||
index: true,
|
index: true,
|
||||||
|
|
@ -47,13 +37,13 @@ export const metadata: Metadata = {
|
||||||
googleBot: {
|
googleBot: {
|
||||||
index: true,
|
index: true,
|
||||||
follow: true,
|
follow: true,
|
||||||
'max-video-preview': -1,
|
"max-video-preview": -1,
|
||||||
'max-image-preview': 'large',
|
"max-image-preview": "large",
|
||||||
'max-snippet': -1,
|
"max-snippet": -1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
verification: {
|
verification: {
|
||||||
google: 'google-site-verification-code',
|
google: "google-site-verification-code",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -115,18 +105,10 @@ export default function RootLayout({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-8 text-sm text-gray-400">
|
<div className="flex gap-8 text-sm text-gray-400">
|
||||||
<a href="#" className="hover:text-white transition-colors">
|
<a href="#" className="hover:text-white transition-colors">Documentation</a>
|
||||||
Documentation
|
<a href="#" className="hover:text-white transition-colors">API Reference</a>
|
||||||
</a>
|
<a href="#" className="hover:text-white transition-colors">Pricing</a>
|
||||||
<a href="#" className="hover:text-white transition-colors">
|
<a href="#" className="hover:text-white transition-colors">Contact</a>
|
||||||
API Reference
|
|
||||||
</a>
|
|
||||||
<a href="#" className="hover:text-white transition-colors">
|
|
||||||
Pricing
|
|
||||||
</a>
|
|
||||||
<a href="#" className="hover:text-white transition-colors">
|
|
||||||
Contact
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8 text-center text-sm text-gray-500">
|
<div className="mt-8 text-center text-sm text-gray-500">
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export default function Home() {
|
||||||
// TODO: Replace with actual API endpoint
|
// TODO: Replace with actual API endpoint
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setStatus('success');
|
setStatus('success');
|
||||||
setMessage("You're on the list! Check your email for beta access details.");
|
setMessage('You\'re on the list! Check your email for beta access details.');
|
||||||
setEmail('');
|
setEmail('');
|
||||||
}, 1000);
|
}, 1000);
|
||||||
};
|
};
|
||||||
|
|
@ -60,11 +60,7 @@ export default function Home() {
|
||||||
disabled={status === 'loading' || status === 'success'}
|
disabled={status === 'loading' || status === 'success'}
|
||||||
className="px-8 py-4 rounded-xl bg-gradient-to-r from-purple-600 to-cyan-600 text-white font-semibold hover:from-purple-500 hover:to-cyan-500 transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-purple-500/25"
|
className="px-8 py-4 rounded-xl bg-gradient-to-r from-purple-600 to-cyan-600 text-white font-semibold hover:from-purple-500 hover:to-cyan-500 transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-purple-500/25"
|
||||||
>
|
>
|
||||||
{status === 'loading'
|
{status === 'loading' ? 'Joining...' : status === 'success' ? 'Joined!' : 'Join Beta'}
|
||||||
? 'Joining...'
|
|
||||||
: status === 'success'
|
|
||||||
? 'Joined!'
|
|
||||||
: 'Join Beta'}
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{status === 'success' && (
|
{status === 'success' && (
|
||||||
|
|
@ -130,10 +126,11 @@ export default function Home() {
|
||||||
<div className="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center mb-4 text-2xl">
|
<div className="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center mb-4 text-2xl">
|
||||||
🚀
|
🚀
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-white mb-3">Instant Integration</h3>
|
<h3 className="text-xl font-semibold text-white mb-3">
|
||||||
|
Instant Integration
|
||||||
|
</h3>
|
||||||
<p className="text-gray-400 leading-relaxed">
|
<p className="text-gray-400 leading-relaxed">
|
||||||
Clean REST API that works with any stack. From hobbyist to enterprise, start
|
Clean REST API that works with any stack. From hobbyist to enterprise, start generating images in minutes.
|
||||||
generating images in minutes.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -142,10 +139,11 @@ export default function Home() {
|
||||||
<div className="w-12 h-12 rounded-xl bg-cyan-500/20 flex items-center justify-center mb-4 text-2xl">
|
<div className="w-12 h-12 rounded-xl bg-cyan-500/20 flex items-center justify-center mb-4 text-2xl">
|
||||||
🎯
|
🎯
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-white mb-3">Reference-Guided Generation</h3>
|
<h3 className="text-xl font-semibold text-white mb-3">
|
||||||
|
Reference-Guided Generation
|
||||||
|
</h3>
|
||||||
<p className="text-gray-400 leading-relaxed">
|
<p className="text-gray-400 leading-relaxed">
|
||||||
Upload up to 3 reference images alongside your prompt. Perfect for brand consistency
|
Upload up to 3 reference images alongside your prompt. Perfect for brand consistency and style matching.
|
||||||
and style matching.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -154,10 +152,11 @@ export default function Home() {
|
||||||
<div className="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center mb-4 text-2xl">
|
<div className="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center mb-4 text-2xl">
|
||||||
✨
|
✨
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-white mb-3">Smart Prompt Enhancement</h3>
|
<h3 className="text-xl font-semibold text-white mb-3">
|
||||||
|
Smart Prompt Enhancement
|
||||||
|
</h3>
|
||||||
<p className="text-gray-400 leading-relaxed">
|
<p className="text-gray-400 leading-relaxed">
|
||||||
AI-powered prompt optimization with language detection. Get better results
|
AI-powered prompt optimization with language detection. Get better results automatically.
|
||||||
automatically.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -166,10 +165,11 @@ export default function Home() {
|
||||||
<div className="w-12 h-12 rounded-xl bg-cyan-500/20 flex items-center justify-center mb-4 text-2xl">
|
<div className="w-12 h-12 rounded-xl bg-cyan-500/20 flex items-center justify-center mb-4 text-2xl">
|
||||||
🔐
|
🔐
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-white mb-3">Enterprise-Ready Security</h3>
|
<h3 className="text-xl font-semibold text-white mb-3">
|
||||||
|
Enterprise-Ready Security
|
||||||
|
</h3>
|
||||||
<p className="text-gray-400 leading-relaxed">
|
<p className="text-gray-400 leading-relaxed">
|
||||||
API key management, rate limiting, multi-tenant architecture. Built for production
|
API key management, rate limiting, multi-tenant architecture. Built for production from day one.
|
||||||
from day one.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -178,10 +178,11 @@ export default function Home() {
|
||||||
<div className="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center mb-4 text-2xl">
|
<div className="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center mb-4 text-2xl">
|
||||||
⚡
|
⚡
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-white mb-3">Dual AI Models</h3>
|
<h3 className="text-xl font-semibold text-white mb-3">
|
||||||
|
Dual AI Models
|
||||||
|
</h3>
|
||||||
<p className="text-gray-400 leading-relaxed">
|
<p className="text-gray-400 leading-relaxed">
|
||||||
Powered by Google Gemini 2.5 Flash and Imagen 4.0. Automatic fallback ensures
|
Powered by Google Gemini 2.5 Flash and Imagen 4.0. Automatic fallback ensures reliability.
|
||||||
reliability.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -190,10 +191,11 @@ export default function Home() {
|
||||||
<div className="w-12 h-12 rounded-xl bg-cyan-500/20 flex items-center justify-center mb-4 text-2xl">
|
<div className="w-12 h-12 rounded-xl bg-cyan-500/20 flex items-center justify-center mb-4 text-2xl">
|
||||||
📦
|
📦
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-white mb-3">Organized Cloud Storage</h3>
|
<h3 className="text-xl font-semibold text-white mb-3">
|
||||||
|
Organized Cloud Storage
|
||||||
|
</h3>
|
||||||
<p className="text-gray-400 leading-relaxed">
|
<p className="text-gray-400 leading-relaxed">
|
||||||
Automatic file organization by org/project/category. CDN-ready URLs for instant
|
Automatic file organization by org/project/category. CDN-ready URLs for instant delivery.
|
||||||
delivery.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -202,7 +204,9 @@ export default function Home() {
|
||||||
{/* CTA Section */}
|
{/* CTA Section */}
|
||||||
<section className="relative z-10 max-w-4xl mx-auto px-6 py-16 md:py-24">
|
<section className="relative z-10 max-w-4xl mx-auto px-6 py-16 md:py-24">
|
||||||
<div className="text-center p-12 rounded-3xl bg-gradient-to-br from-purple-600/20 via-cyan-600/20 to-purple-600/20 border border-purple-500/30 backdrop-blur-sm">
|
<div className="text-center p-12 rounded-3xl bg-gradient-to-br from-purple-600/20 via-cyan-600/20 to-purple-600/20 border border-purple-500/30 backdrop-blur-sm">
|
||||||
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">Join the Beta Program</h2>
|
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||||
|
Join the Beta Program
|
||||||
|
</h2>
|
||||||
<p className="text-xl text-gray-300 mb-8">
|
<p className="text-xl text-gray-300 mb-8">
|
||||||
Get early access, shape the product, and lock in founder pricing.
|
Get early access, shape the product, and lock in founder pricing.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,7 @@ export default function AdminButton({
|
||||||
disabled = false,
|
disabled = false,
|
||||||
className = '',
|
className = '',
|
||||||
}: AdminButtonProps) {
|
}: AdminButtonProps) {
|
||||||
const baseClasses =
|
const baseClasses = 'px-6 py-3 font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed';
|
||||||
'px-6 py-3 font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed';
|
|
||||||
|
|
||||||
const variantClasses = {
|
const variantClasses = {
|
||||||
primary: 'bg-amber-600 text-white hover:bg-amber-700 shadow-lg shadow-amber-900/30',
|
primary: 'bg-amber-600 text-white hover:bg-amber-700 shadow-lg shadow-amber-900/30',
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,9 @@ export default function CopyButton({ text, label = 'Copy', className = '' }: Cop
|
||||||
<button
|
<button
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
copied ? 'bg-green-600 text-white' : 'bg-slate-700 text-slate-200 hover:bg-slate-600'
|
copied
|
||||||
|
? 'bg-green-600 text-white'
|
||||||
|
: 'bg-slate-700 text-slate-200 hover:bg-slate-600'
|
||||||
} ${className}`}
|
} ${className}`}
|
||||||
>
|
>
|
||||||
{copied ? '✓ Copied!' : label}
|
{copied ? '✓ Copied!' : label}
|
||||||
|
|
|
||||||
|
|
@ -171,10 +171,7 @@ export function AdvancedOptionsModal({
|
||||||
|
|
||||||
{/* Negative Prompts */}
|
{/* Negative Prompts */}
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label htmlFor="negative-prompts" className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
htmlFor="negative-prompts"
|
|
||||||
className="block text-sm font-medium text-gray-300 mb-2"
|
|
||||||
>
|
|
||||||
Negative Prompts
|
Negative Prompts
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,7 @@ interface GenerationTimerProps {
|
||||||
variant?: 'inline' | 'badge';
|
variant?: 'inline' | 'badge';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GenerationTimer({
|
export function GenerationTimer({ isGenerating, startTime, variant = 'inline' }: GenerationTimerProps) {
|
||||||
isGenerating,
|
|
||||||
startTime,
|
|
||||||
variant = 'inline',
|
|
||||||
}: GenerationTimerProps) {
|
|
||||||
const [elapsed, setElapsed] = useState(0);
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -39,18 +35,8 @@ export function GenerationTimer({
|
||||||
if (variant === 'badge') {
|
if (variant === 'badge') {
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex items-center gap-1.5 px-2 py-1 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-md text-xs text-gray-300">
|
<div className="inline-flex items-center gap-1.5 px-2 py-1 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-md text-xs text-gray-300">
|
||||||
<svg
|
<svg className="w-3.5 h-3.5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
className="w-3.5 h-3.5 text-amber-400"
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
<span className="font-medium">{formatTime(elapsed)}s</span>
|
<span className="font-medium">{formatTime(elapsed)}s</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -60,19 +46,8 @@ export function GenerationTimer({
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1.5 text-sm text-gray-400">
|
<span className="inline-flex items-center gap-1.5 text-sm text-gray-400">
|
||||||
<svg className="w-4 h-4 text-amber-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 text-amber-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
<circle
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
className="opacity-25"
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
</svg>
|
||||||
<span className="font-medium">{formatTime(elapsed)}s</span>
|
<span className="font-medium">{formatTime(elapsed)}s</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -91,12 +66,7 @@ export function CompletedTimerBadge({ durationMs }: CompletedTimerBadgeProps) {
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex items-center gap-1 px-2 py-1 bg-green-900/20 border border-green-700/50 rounded text-xs text-green-400">
|
<div className="inline-flex items-center gap-1 px-2 py-1 bg-green-900/20 border border-green-700/50 rounded text-xs text-green-400">
|
||||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
<span className="font-medium">{formatTime(durationMs)}s</span>
|
<span className="font-medium">{formatTime(durationMs)}s</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,11 @@ export function InspectMode({ leftData, rightData, onCopy }: InspectModeProps) {
|
||||||
onCopy={onCopy}
|
onCopy={onCopy}
|
||||||
defaultOpen={true}
|
defaultOpen={true}
|
||||||
/>
|
/>
|
||||||
<CollapsibleSection title="API Response" data={leftData.response} onCopy={onCopy} />
|
<CollapsibleSection
|
||||||
|
title="API Response"
|
||||||
|
data={leftData.response}
|
||||||
|
onCopy={onCopy}
|
||||||
|
/>
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
title="Gemini Parameters"
|
title="Gemini Parameters"
|
||||||
data={leftData.geminiParams}
|
data={leftData.geminiParams}
|
||||||
|
|
@ -51,7 +55,11 @@ export function InspectMode({ leftData, rightData, onCopy }: InspectModeProps) {
|
||||||
onCopy={onCopy}
|
onCopy={onCopy}
|
||||||
defaultOpen={true}
|
defaultOpen={true}
|
||||||
/>
|
/>
|
||||||
<CollapsibleSection title="API Response" data={rightData.response} onCopy={onCopy} />
|
<CollapsibleSection
|
||||||
|
title="API Response"
|
||||||
|
data={rightData.response}
|
||||||
|
onCopy={onCopy}
|
||||||
|
/>
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
title="Gemini Parameters"
|
title="Gemini Parameters"
|
||||||
data={rightData.geminiParams}
|
data={rightData.geminiParams}
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,12 @@ export function MinimizedApiKey({
|
||||||
className="w-8 h-8 rounded-lg bg-slate-800 hover:bg-slate-700 text-gray-400 hover:text-white flex items-center justify-center transition-colors"
|
className="w-8 h-8 rounded-lg bg-slate-800 hover:bg-slate-700 text-gray-400 hover:text-white flex items-center justify-center transition-colors"
|
||||||
aria-label="Minimize API key details"
|
aria-label="Minimize API key details"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
|
|
@ -86,27 +91,12 @@ export function MinimizedApiKey({
|
||||||
>
|
>
|
||||||
{keyVisible ? (
|
{keyVisible ? (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
strokeLinecap="round"
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -34,12 +34,7 @@ export function PromptReuseButton({ prompt, onReuse, label }: PromptReuseButtonP
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
<span className="font-medium">Reuse</span>
|
<span className="font-medium">Reuse</span>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -75,22 +75,15 @@ export function ResultCard({
|
||||||
if (!result.enhancementOptions) return '';
|
if (!result.enhancementOptions) return '';
|
||||||
|
|
||||||
const opts: any = {};
|
const opts: any = {};
|
||||||
if (result.enhancementOptions.imageStyle)
|
if (result.enhancementOptions.imageStyle) opts.imageStyle = result.enhancementOptions.imageStyle;
|
||||||
opts.imageStyle = result.enhancementOptions.imageStyle;
|
if (result.enhancementOptions.aspectRatio) opts.aspectRatio = result.enhancementOptions.aspectRatio;
|
||||||
if (result.enhancementOptions.aspectRatio)
|
|
||||||
opts.aspectRatio = result.enhancementOptions.aspectRatio;
|
|
||||||
if (result.enhancementOptions.mood) opts.mood = result.enhancementOptions.mood;
|
if (result.enhancementOptions.mood) opts.mood = result.enhancementOptions.mood;
|
||||||
if (result.enhancementOptions.lighting) opts.lighting = result.enhancementOptions.lighting;
|
if (result.enhancementOptions.lighting) opts.lighting = result.enhancementOptions.lighting;
|
||||||
if (result.enhancementOptions.cameraAngle)
|
if (result.enhancementOptions.cameraAngle) opts.cameraAngle = result.enhancementOptions.cameraAngle;
|
||||||
opts.cameraAngle = result.enhancementOptions.cameraAngle;
|
if (result.enhancementOptions.negativePrompts) opts.negativePrompts = result.enhancementOptions.negativePrompts;
|
||||||
if (result.enhancementOptions.negativePrompts)
|
|
||||||
opts.negativePrompts = result.enhancementOptions.negativePrompts;
|
|
||||||
|
|
||||||
if (Object.keys(opts).length === 0) return '';
|
if (Object.keys(opts).length === 0) return '';
|
||||||
return JSON.stringify(opts, null, 2)
|
return JSON.stringify(opts, null, 2).split('\n').map((line, i) => i === 0 ? line : ` ${line}`).join('\n');
|
||||||
.split('\n')
|
|
||||||
.map((line, i) => (i === 0 ? line : ` ${line}`))
|
|
||||||
.join('\n');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const enhancementOptionsJson = buildEnhancementOptionsJson();
|
const enhancementOptionsJson = buildEnhancementOptionsJson();
|
||||||
|
|
@ -181,7 +174,9 @@ X-API-Key: ${apiKey}
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-4 flex items-center justify-between gap-3 flex-wrap">
|
<div className="mb-4 flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm text-gray-500">{result.timestamp.toLocaleString()}</span>
|
<span className="text-sm text-gray-500">
|
||||||
|
{result.timestamp.toLocaleString()}
|
||||||
|
</span>
|
||||||
{result.durationMs && <CompletedTimerBadge durationMs={result.durationMs} />}
|
{result.durationMs && <CompletedTimerBadge durationMs={result.durationMs} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -190,7 +185,9 @@ X-API-Key: ${apiKey}
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('preview')}
|
onClick={() => setViewMode('preview')}
|
||||||
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
|
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
|
||||||
viewMode === 'preview' ? 'bg-amber-600 text-white' : 'text-gray-400 hover:text-white'
|
viewMode === 'preview'
|
||||||
|
? 'bg-amber-600 text-white'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
aria-pressed={viewMode === 'preview'}
|
aria-pressed={viewMode === 'preview'}
|
||||||
>
|
>
|
||||||
|
|
@ -199,7 +196,9 @@ X-API-Key: ${apiKey}
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('inspect')}
|
onClick={() => setViewMode('inspect')}
|
||||||
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
|
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
|
||||||
viewMode === 'inspect' ? 'bg-amber-600 text-white' : 'text-gray-400 hover:text-white'
|
viewMode === 'inspect'
|
||||||
|
? 'bg-amber-600 text-white'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
aria-pressed={viewMode === 'inspect'}
|
aria-pressed={viewMode === 'inspect'}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,7 @@ interface CreateKeyResponse {
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bootstrapMasterKey(): Promise<{
|
export async function bootstrapMasterKey(): Promise<{ success: boolean; apiKey?: string; error?: string }> {
|
||||||
success: boolean;
|
|
||||||
apiKey?: string;
|
|
||||||
error?: string;
|
|
||||||
}> {
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/bootstrap/initial-key`, {
|
const response = await fetch(`${API_BASE_URL}/api/bootstrap/initial-key`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -55,7 +51,7 @@ export async function bootstrapMasterKey(): Promise<{
|
||||||
export async function createProjectApiKey(
|
export async function createProjectApiKey(
|
||||||
masterKey: string,
|
masterKey: string,
|
||||||
orgSlug: string,
|
orgSlug: string,
|
||||||
projectSlug: string,
|
projectSlug: string
|
||||||
): Promise<{ success: boolean; apiKey?: string; error?: string }> {
|
): Promise<{ success: boolean; apiKey?: string; error?: string }> {
|
||||||
try {
|
try {
|
||||||
// Call API service to create the project key (API auto-creates org/project)
|
// Call API service to create the project key (API auto-creates org/project)
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export async function getOrCreateProject(organizationId: string, name: string):
|
||||||
export async function getOrCreateOrgAndProject(
|
export async function getOrCreateOrgAndProject(
|
||||||
email: string,
|
email: string,
|
||||||
orgName: string,
|
orgName: string,
|
||||||
projectName: string,
|
projectName: string
|
||||||
): Promise<{ organization: Organization; project: Project }> {
|
): Promise<{ organization: Organization; project: Project }> {
|
||||||
// Get or create organization
|
// Get or create organization
|
||||||
const organization = await getOrCreateOrganization(email, orgName);
|
const organization = await getOrCreateOrganization(email, orgName);
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,7 @@ import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
import postgres from 'postgres';
|
import postgres from 'postgres';
|
||||||
import * as schema from '@banatie/database';
|
import * as schema from '@banatie/database';
|
||||||
|
|
||||||
const connectionString =
|
const connectionString = process.env.DATABASE_URL || 'postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db';
|
||||||
process.env.DATABASE_URL ||
|
|
||||||
'postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db';
|
|
||||||
|
|
||||||
// Create postgres client
|
// Create postgres client
|
||||||
const client = postgres(connectionString);
|
const client = postgres(connectionString);
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,17 @@ export async function getOrganizationByEmail(email: string): Promise<Organizatio
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createOrganization(data: NewOrganization): Promise<Organization> {
|
export async function createOrganization(data: NewOrganization): Promise<Organization> {
|
||||||
const [org] = await db.insert(organizations).values(data).returning();
|
const [org] = await db
|
||||||
|
.insert(organizations)
|
||||||
|
.values(data)
|
||||||
|
.returning();
|
||||||
|
|
||||||
return org!;
|
return org!;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listOrganizations(): Promise<Organization[]> {
|
export async function listOrganizations(): Promise<Organization[]> {
|
||||||
return db.select().from(organizations).orderBy(organizations.createdAt);
|
return db
|
||||||
|
.select()
|
||||||
|
.from(organizations)
|
||||||
|
.orderBy(organizations.createdAt);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,26 @@ import { db } from '../client';
|
||||||
import { projects, type Project, type NewProject } from '@banatie/database';
|
import { projects, type Project, type NewProject } from '@banatie/database';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
|
||||||
export async function getProjectByName(
|
export async function getProjectByName(organizationId: string, name: string): Promise<Project | null> {
|
||||||
organizationId: string,
|
|
||||||
name: string,
|
|
||||||
): Promise<Project | null> {
|
|
||||||
const [project] = await db
|
const [project] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(projects)
|
.from(projects)
|
||||||
.where(and(eq(projects.organizationId, organizationId), eq(projects.name, name)))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(projects.organizationId, organizationId),
|
||||||
|
eq(projects.name, name)
|
||||||
|
)
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
return project || null;
|
return project || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createProject(data: NewProject): Promise<Project> {
|
export async function createProject(data: NewProject): Promise<Project> {
|
||||||
const [project] = await db.insert(projects).values(data).returning();
|
const [project] = await db
|
||||||
|
.insert(projects)
|
||||||
|
.values(data)
|
||||||
|
.returning();
|
||||||
|
|
||||||
return project!;
|
return project!;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
{
|
|
||||||
"extends": ["next/core-web-vitals", "prettier"],
|
|
||||||
"plugins": ["prettier"],
|
|
||||||
"rules": {
|
|
||||||
"prettier/prettier": "error"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -47,4 +47,4 @@ pnpm dev
|
||||||
- [ ] Integrate with Banatie API
|
- [ ] Integrate with Banatie API
|
||||||
- [ ] Add usage tracking
|
- [ ] Add usage tracking
|
||||||
- [ ] Implement team management
|
- [ ] Implement team management
|
||||||
- [ ] Add billing portal
|
- [ ] Add billing portal
|
||||||
|
|
@ -11,6 +11,6 @@ const nextConfig = {
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
module.exports = nextConfig;
|
module.exports = nextConfig
|
||||||
|
|
@ -36,4 +36,4 @@
|
||||||
"node": ">=18.0.0",
|
"node": ">=18.0.0",
|
||||||
"pnpm": ">=8.0.0"
|
"pnpm": ">=8.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,16 +1,22 @@
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Banatie Studio - AI Image Generation SaaS',
|
title: 'Banatie Studio - AI Image Generation SaaS',
|
||||||
description: 'Professional AI image generation platform with subscription management',
|
description: 'Professional AI image generation platform with subscription management',
|
||||||
};
|
}
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className="min-h-screen bg-gray-50">
|
<body className="min-h-screen bg-gray-50">
|
||||||
<div className="min-h-screen">{children}</div>
|
<div className="min-h-screen">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -3,10 +3,12 @@ export default function StudioPage() {
|
||||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||||
<div className="container mx-auto px-4 py-12">
|
<div className="container mx-auto px-4 py-12">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-5xl font-bold text-gray-900 mb-6">Banatie Studio</h1>
|
<h1 className="text-5xl font-bold text-gray-900 mb-6">
|
||||||
|
Banatie Studio
|
||||||
|
</h1>
|
||||||
<p className="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
|
<p className="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
|
||||||
Professional AI image generation platform with subscription management, user
|
Professional AI image generation platform with subscription management,
|
||||||
authentication, and billing integration.
|
user authentication, and billing integration.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-8 mt-12 max-w-4xl mx-auto">
|
<div className="grid md:grid-cols-3 gap-8 mt-12 max-w-4xl mx-auto">
|
||||||
|
|
@ -35,13 +37,13 @@ export default function StudioPage() {
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 max-w-2xl mx-auto">
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 max-w-2xl mx-auto">
|
||||||
<p className="text-yellow-800">
|
<p className="text-yellow-800">
|
||||||
<strong>Coming Soon:</strong> This SaaS platform is under development. Based on
|
<strong>Coming Soon:</strong> This SaaS platform is under development.
|
||||||
Vercel's Next.js SaaS starter template.
|
Based on Vercel's Next.js SaaS starter template.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -28,4 +28,4 @@
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
24
demo.test.ts
24
demo.test.ts
|
|
@ -1,24 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
|
|
||||||
describe("Demo Test Suite", () => {
|
|
||||||
it("should pass a basic assertion", () => {
|
|
||||||
expect(1 + 1).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should work with strings", () => {
|
|
||||||
const message = "Hello Vitest";
|
|
||||||
expect(message).toContain("Vitest");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should work with arrays", () => {
|
|
||||||
const numbers = [1, 2, 3, 4, 5];
|
|
||||||
expect(numbers).toHaveLength(5);
|
|
||||||
expect(numbers).toContain(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should work with objects", () => {
|
|
||||||
const user = { name: "Test User", age: 25 };
|
|
||||||
expect(user).toHaveProperty("name");
|
|
||||||
expect(user.age).toBeGreaterThan(18);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -46,6 +46,26 @@ DELETE {{base}}/api/admin/keys/KEY_ID_HERE
|
||||||
X-API-Key: {{masterKey}}
|
X-API-Key: {{masterKey}}
|
||||||
|
|
||||||
|
|
||||||
|
### Enhance Prompt (Requires API Key)
|
||||||
|
|
||||||
|
POST {{base}}/api/enhance
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Два мага сражаются в снежном лесу. У одного из них в руках посох, из которого вырывается молния, а другой маг защищается щитом из льда. Вокруг них падают снежинки, и на заднем плане видны заснеженные деревья и горы.",
|
||||||
|
"options": {
|
||||||
|
"imageStyle": "photorealistic",
|
||||||
|
"aspectRatio": "landscape",
|
||||||
|
"mood": "serene and peaceful",
|
||||||
|
"lighting": "golden hour",
|
||||||
|
"cameraAngle": "wide shot",
|
||||||
|
"outputFormat": "detailed",
|
||||||
|
"negativePrompts": ["blurry", "low quality"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
### Generate Image from Text (Requires API Key)
|
### Generate Image from Text (Requires API Key)
|
||||||
|
|
||||||
POST {{base}}/api/text-to-image
|
POST {{base}}/api/text-to-image
|
||||||
|
|
@ -58,7 +78,37 @@ X-API-Key: {{apiKey}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
### Generate Image - Text to Image (alternative format)
|
### Generate Image with Files (Requires API Key)
|
||||||
|
|
||||||
|
POST {{base}}/api/generate
|
||||||
|
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
------WebKitFormBoundary
|
||||||
|
Content-Disposition: form-data; name="prompt"
|
||||||
|
|
||||||
|
A majestic dragon soaring through a crystal cave filled with glowing blue crystals, sunbeams piercing through cracks in the ceiling creating dramatic lighting, highly detailed fantasy art style
|
||||||
|
------WebKitFormBoundary
|
||||||
|
Content-Disposition: form-data; name="filename"
|
||||||
|
|
||||||
|
dragon-crystal-cave
|
||||||
|
------WebKitFormBoundary
|
||||||
|
Content-Disposition: form-data; name="autoEnhance"
|
||||||
|
|
||||||
|
true
|
||||||
|
------WebKitFormBoundary
|
||||||
|
Content-Disposition: form-data; name="enhancementOptions"
|
||||||
|
|
||||||
|
{"imageStyle":"illustration","aspectRatio":"landscape","mood":"mystical and dramatic","lighting":"magical glow with sunbeams","cameraAngle":"wide shot","negativePrompts":["blurry","low quality","amateur"]}
|
||||||
|
------WebKitFormBoundary
|
||||||
|
Content-Disposition: form-data; name="referenceImages"; filename="reference.jpg"
|
||||||
|
Content-Type: image/jpeg
|
||||||
|
|
||||||
|
< ./reference.jpg
|
||||||
|
------WebKitFormBoundary--
|
||||||
|
|
||||||
|
|
||||||
|
### Generate Image - Text to Image
|
||||||
POST http://localhost:3000/api/text-to-image
|
POST http://localhost:3000/api/text-to-image
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
X-API-Key: bnt_61ba018f01474491cbaacec4509220d7154fffcd011f005eece4dba7889fba99
|
X-API-Key: bnt_61ba018f01474491cbaacec4509220d7154fffcd011f005eece4dba7889fba99
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ This document defines the complete containerization and deployment architecture
|
||||||
## Architecture Summary
|
## Architecture Summary
|
||||||
|
|
||||||
### Core Principles
|
### Core Principles
|
||||||
|
|
||||||
- **Complete Service Isolation**: Banatie ecosystem is fully isolated from core VPS services
|
- **Complete Service Isolation**: Banatie ecosystem is fully isolated from core VPS services
|
||||||
- **Dedicated Database**: Separate PostgreSQL container exclusively for Banatie
|
- **Dedicated Database**: Separate PostgreSQL container exclusively for Banatie
|
||||||
- **S3-Compatible Storage**: MinIO for scalable object storage with multi-tenant support
|
- **S3-Compatible Storage**: MinIO for scalable object storage with multi-tenant support
|
||||||
|
|
@ -31,31 +30,29 @@ Banatie Ecosystem (Isolated)
|
||||||
networks:
|
networks:
|
||||||
banatie-network:
|
banatie-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
internal: true # No external internet access
|
internal: true # No external internet access
|
||||||
|
|
||||||
proxy-network:
|
proxy-network:
|
||||||
external: true # Existing Caddy reverse proxy network
|
external: true # Existing Caddy reverse proxy network
|
||||||
```
|
```
|
||||||
|
|
||||||
### Network Access Matrix
|
### Network Access Matrix
|
||||||
|
|
||||||
| Service | banatie-network | proxy-network | External Access |
|
| Service | banatie-network | proxy-network | External Access |
|
||||||
| ----------- | --------------- | --------------- | --------------- |
|
|---------|----------------|---------------|-----------------|
|
||||||
| Banatie App | ✅ Internal | ✅ HTTP only | ❌ Direct |
|
| Banatie App | ✅ Internal | ✅ HTTP only | ❌ Direct |
|
||||||
| PostgreSQL | ✅ Internal | ❌ None | ❌ None |
|
| PostgreSQL | ✅ Internal | ❌ None | ❌ None |
|
||||||
| MinIO | ✅ Internal | ✅ Console only | ❌ Direct |
|
| MinIO | ✅ Internal | ✅ Console only | ❌ Direct |
|
||||||
| Caddy Proxy | ❌ None | ✅ Routing | ✅ Internet |
|
| Caddy Proxy | ❌ None | ✅ Routing | ✅ Internet |
|
||||||
|
|
||||||
### VPS Integration Points
|
### VPS Integration Points
|
||||||
|
|
||||||
**Isolation from Core Services:**
|
**Isolation from Core Services:**
|
||||||
|
|
||||||
- **NO** shared resources with existing PostgreSQL (`/opt/services/`)
|
- **NO** shared resources with existing PostgreSQL (`/opt/services/`)
|
||||||
- **NO** access to NextCloud, Gitea, or other core services
|
- **NO** access to NextCloud, Gitea, or other core services
|
||||||
- **ONLY** connection point: Caddy reverse proxy for HTTP routing
|
- **ONLY** connection point: Caddy reverse proxy for HTTP routing
|
||||||
|
|
||||||
**Shared Infrastructure:**
|
**Shared Infrastructure:**
|
||||||
|
|
||||||
- Caddy reverse proxy for SSL termination and routing
|
- Caddy reverse proxy for SSL termination and routing
|
||||||
- Host filesystem for persistent data storage
|
- Host filesystem for persistent data storage
|
||||||
- UFW firewall rules and security policies
|
- UFW firewall rules and security policies
|
||||||
|
|
@ -63,7 +60,6 @@ networks:
|
||||||
## Directory Structure
|
## Directory Structure
|
||||||
|
|
||||||
### Development Environment
|
### Development Environment
|
||||||
|
|
||||||
```
|
```
|
||||||
banatie-service/
|
banatie-service/
|
||||||
├── src/ # Application source code
|
├── src/ # Application source code
|
||||||
|
|
@ -77,7 +73,6 @@ banatie-service/
|
||||||
```
|
```
|
||||||
|
|
||||||
### Production Environment (VPS)
|
### Production Environment (VPS)
|
||||||
|
|
||||||
```
|
```
|
||||||
/opt/banatie/ # Isolated deployment directory
|
/opt/banatie/ # Isolated deployment directory
|
||||||
├── docker-compose.yml # Production configuration
|
├── docker-compose.yml # Production configuration
|
||||||
|
|
@ -120,7 +115,6 @@ CMD ["node", "dist/server.js"]
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key Features:**
|
**Key Features:**
|
||||||
|
|
||||||
- Hot-reload support in development
|
- Hot-reload support in development
|
||||||
- Production-optimized build with dependencies pruning
|
- Production-optimized build with dependencies pruning
|
||||||
- Health check endpoints for monitoring
|
- Health check endpoints for monitoring
|
||||||
|
|
@ -133,7 +127,6 @@ CMD ["node", "dist/server.js"]
|
||||||
**Data**: User accounts, image metadata, upload sessions, organization settings
|
**Data**: User accounts, image metadata, upload sessions, organization settings
|
||||||
|
|
||||||
**Database Schema:**
|
**Database Schema:**
|
||||||
|
|
||||||
- `users` - User authentication and profiles
|
- `users` - User authentication and profiles
|
||||||
- `organizations` - Multi-tenant organization data
|
- `organizations` - Multi-tenant organization data
|
||||||
- `images` - Generated image metadata and references
|
- `images` - Generated image metadata and references
|
||||||
|
|
@ -147,7 +140,6 @@ CMD ["node", "dist/server.js"]
|
||||||
**Ports**: 9000 (S3 API), 9001 (Web Console)
|
**Ports**: 9000 (S3 API), 9001 (Web Console)
|
||||||
|
|
||||||
**Storage Strategy:**
|
**Storage Strategy:**
|
||||||
|
|
||||||
- Persistent volumes for data durability
|
- Persistent volumes for data durability
|
||||||
- Bucket-per-organization architecture
|
- Bucket-per-organization architecture
|
||||||
- Lifecycle policies for temporary file cleanup
|
- Lifecycle policies for temporary file cleanup
|
||||||
|
|
@ -177,14 +169,12 @@ banatie-{org-id}/ # Organization bucket (e.g., banatie-demo)
|
||||||
### Demo Organization Setup
|
### Demo Organization Setup
|
||||||
|
|
||||||
**Default Configuration:**
|
**Default Configuration:**
|
||||||
|
|
||||||
- **Organization**: `demo` (org-id: demo)
|
- **Organization**: `demo` (org-id: demo)
|
||||||
- **User**: `guest` (user-id: guest)
|
- **User**: `guest` (user-id: guest)
|
||||||
- **Bucket**: `banatie-demo`
|
- **Bucket**: `banatie-demo`
|
||||||
- **Access**: Public read for demo images
|
- **Access**: Public read for demo images
|
||||||
|
|
||||||
**Demo Bucket Structure:**
|
**Demo Bucket Structure:**
|
||||||
|
|
||||||
```
|
```
|
||||||
banatie-demo/
|
banatie-demo/
|
||||||
├── users/
|
├── users/
|
||||||
|
|
@ -278,9 +268,9 @@ services:
|
||||||
target: development
|
target: development
|
||||||
container_name: banatie-app-dev
|
container_name: banatie-app-dev
|
||||||
ports:
|
ports:
|
||||||
- '3000:3000'
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./src:/app/src # Hot reload
|
- ./src:/app/src # Hot reload
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
networks:
|
networks:
|
||||||
- banatie-dev
|
- banatie-dev
|
||||||
|
|
@ -296,7 +286,7 @@ services:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
container_name: banatie-postgres-dev
|
container_name: banatie-postgres-dev
|
||||||
ports:
|
ports:
|
||||||
- '5433:5432' # Avoid conflicts with system PostgreSQL
|
- "5433:5432" # Avoid conflicts with system PostgreSQL
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/postgres:/var/lib/postgresql/data
|
- ./data/postgres:/var/lib/postgresql/data
|
||||||
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/01-init.sql
|
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/01-init.sql
|
||||||
|
|
@ -307,7 +297,7 @@ services:
|
||||||
POSTGRES_USER: banatie_user
|
POSTGRES_USER: banatie_user
|
||||||
POSTGRES_PASSWORD: development_password
|
POSTGRES_PASSWORD: development_password
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'pg_isready -U banatie_user -d banatie_db']
|
test: ["CMD-SHELL", "pg_isready -U banatie_user -d banatie_db"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
@ -316,8 +306,8 @@ services:
|
||||||
image: minio/minio:latest
|
image: minio/minio:latest
|
||||||
container_name: banatie-minio-dev
|
container_name: banatie-minio-dev
|
||||||
ports:
|
ports:
|
||||||
- '9000:9000' # S3 API
|
- "9000:9000" # S3 API
|
||||||
- '9001:9001' # Web Console
|
- "9001:9001" # Web Console
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/minio:/data
|
- ./data/minio:/data
|
||||||
networks:
|
networks:
|
||||||
|
|
@ -327,7 +317,7 @@ services:
|
||||||
MINIO_ROOT_PASSWORD: minioadmin
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
command: server /data --console-address ":9001"
|
command: server /data --console-address ":9001"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
@ -365,7 +355,7 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD', 'curl', '-f', 'http://localhost:3000/health']
|
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
@ -385,7 +375,7 @@ services:
|
||||||
POSTGRES_USER: banatie_user
|
POSTGRES_USER: banatie_user
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'pg_isready -U banatie_user -d banatie_db']
|
test: ["CMD-SHELL", "pg_isready -U banatie_user -d banatie_db"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
@ -404,7 +394,7 @@ services:
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
|
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
|
||||||
command: server /data --console-address ":9001"
|
command: server /data --console-address ":9001"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
@ -412,10 +402,10 @@ services:
|
||||||
networks:
|
networks:
|
||||||
banatie-network:
|
banatie-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
internal: true # No external internet access
|
internal: true # No external internet access
|
||||||
|
|
||||||
proxy-network:
|
proxy-network:
|
||||||
external: true # Existing Caddy network
|
external: true # Existing Caddy network
|
||||||
```
|
```
|
||||||
|
|
||||||
## Caddy Integration
|
## Caddy Integration
|
||||||
|
|
@ -514,29 +504,13 @@ export interface StorageService {
|
||||||
listBuckets(): Promise<string[]>;
|
listBuckets(): Promise<string[]>;
|
||||||
|
|
||||||
// File operations
|
// File operations
|
||||||
uploadFile(
|
uploadFile(orgId: string, userId: string, fileName: string, buffer: Buffer, contentType: string): Promise<string>;
|
||||||
orgId: string,
|
|
||||||
userId: string,
|
|
||||||
fileName: string,
|
|
||||||
buffer: Buffer,
|
|
||||||
contentType: string,
|
|
||||||
): Promise<string>;
|
|
||||||
downloadFile(orgId: string, userId: string, fileName: string): Promise<Buffer>;
|
downloadFile(orgId: string, userId: string, fileName: string): Promise<Buffer>;
|
||||||
deleteFile(orgId: string, userId: string, fileName: string): Promise<void>;
|
deleteFile(orgId: string, userId: string, fileName: string): Promise<void>;
|
||||||
|
|
||||||
// URL generation
|
// URL generation
|
||||||
getPresignedUploadUrl(
|
getPresignedUploadUrl(orgId: string, userId: string, fileName: string, expirySeconds: number): Promise<string>;
|
||||||
orgId: string,
|
getPresignedDownloadUrl(orgId: string, userId: string, fileName: string, expirySeconds: number): Promise<string>;
|
||||||
userId: string,
|
|
||||||
fileName: string,
|
|
||||||
expirySeconds: number,
|
|
||||||
): Promise<string>;
|
|
||||||
getPresignedDownloadUrl(
|
|
||||||
orgId: string,
|
|
||||||
userId: string,
|
|
||||||
fileName: string,
|
|
||||||
expirySeconds: number,
|
|
||||||
): Promise<string>;
|
|
||||||
|
|
||||||
// File management
|
// File management
|
||||||
listUserFiles(orgId: string, userId: string): Promise<FileMetadata[]>;
|
listUserFiles(orgId: string, userId: string): Promise<FileMetadata[]>;
|
||||||
|
|
@ -562,14 +536,14 @@ export class MinioStorageService implements StorageService {
|
||||||
accessKey: string,
|
accessKey: string,
|
||||||
secretKey: string,
|
secretKey: string,
|
||||||
useSSL: boolean = false,
|
useSSL: boolean = false,
|
||||||
bucketPrefix: string = 'banatie',
|
bucketPrefix: string = 'banatie'
|
||||||
) {
|
) {
|
||||||
this.client = new MinioClient({
|
this.client = new MinioClient({
|
||||||
endPoint: endpoint.replace(/^https?:\/\//, ''),
|
endPoint: endpoint.replace(/^https?:\/\//, ''),
|
||||||
port: useSSL ? 443 : 9000,
|
port: useSSL ? 443 : 9000,
|
||||||
useSSL,
|
useSSL,
|
||||||
accessKey,
|
accessKey,
|
||||||
secretKey,
|
secretKey
|
||||||
});
|
});
|
||||||
this.bucketPrefix = bucketPrefix;
|
this.bucketPrefix = bucketPrefix;
|
||||||
}
|
}
|
||||||
|
|
@ -578,11 +552,7 @@ export class MinioStorageService implements StorageService {
|
||||||
return `${this.bucketPrefix}-${orgId}`;
|
return `${this.bucketPrefix}-${orgId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getFilePath(
|
private getFilePath(userId: string, category: 'generated' | 'references' | 'temp', fileName: string): string {
|
||||||
userId: string,
|
|
||||||
category: 'generated' | 'references' | 'temp',
|
|
||||||
fileName: string,
|
|
||||||
): string {
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const year = now.getFullYear();
|
const year = now.getFullYear();
|
||||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
|
@ -669,27 +639,23 @@ INSERT INTO users (id, organization_id, username, role) VALUES
|
||||||
### Local Development Setup
|
### Local Development Setup
|
||||||
|
|
||||||
1. **Clone Repository**
|
1. **Clone Repository**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repository-url>
|
git clone <repository-url>
|
||||||
cd banatie-service
|
cd banatie-service
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Environment Configuration**
|
2. **Environment Configuration**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env with development settings
|
# Edit .env with development settings
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Start Development Environment**
|
3. **Start Development Environment**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Verify Services**
|
4. **Verify Services**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check service status
|
# Check service status
|
||||||
docker-compose ps
|
docker-compose ps
|
||||||
|
|
@ -704,7 +670,6 @@ docker-compose logs -f app
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Development with Hot Reload**
|
5. **Development with Hot Reload**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Edit source files in src/
|
# Edit source files in src/
|
||||||
# Changes automatically reload in container
|
# Changes automatically reload in container
|
||||||
|
|
@ -728,7 +693,6 @@ docker-compose exec app pnpm lint
|
||||||
### VPS Deployment Process
|
### VPS Deployment Process
|
||||||
|
|
||||||
1. **Prepare VPS Directory**
|
1. **Prepare VPS Directory**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# SSH to VPS
|
# SSH to VPS
|
||||||
ssh usul-vps
|
ssh usul-vps
|
||||||
|
|
@ -740,7 +704,6 @@ cd /opt/banatie
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Clone and Configure**
|
2. **Clone and Configure**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone repository
|
# Clone repository
|
||||||
git clone <repository-url> .
|
git clone <repository-url> .
|
||||||
|
|
@ -751,7 +714,6 @@ cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Generate Secure Credentials**
|
3. **Generate Secure Credentials**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Generate secure passwords
|
# Generate secure passwords
|
||||||
DB_PASSWORD=$(openssl rand -base64 32 | tr -d '\n\r ')
|
DB_PASSWORD=$(openssl rand -base64 32 | tr -d '\n\r ')
|
||||||
|
|
@ -770,7 +732,6 @@ chmod 600 .env
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Update Caddy Configuration**
|
4. **Update Caddy Configuration**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Add Banatie routes to Caddyfile
|
# Add Banatie routes to Caddyfile
|
||||||
sudo nano /opt/services/configs/caddy/Caddyfile
|
sudo nano /opt/services/configs/caddy/Caddyfile
|
||||||
|
|
@ -782,7 +743,6 @@ cd /opt/services
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Deploy Services**
|
5. **Deploy Services**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/banatie
|
cd /opt/banatie
|
||||||
|
|
||||||
|
|
@ -795,7 +755,6 @@ docker-compose logs -f
|
||||||
```
|
```
|
||||||
|
|
||||||
6. **Verify Deployment**
|
6. **Verify Deployment**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check health endpoints
|
# Check health endpoints
|
||||||
curl https://banatie.app/health
|
curl https://banatie.app/health
|
||||||
|
|
@ -831,7 +790,6 @@ curl https://banatie.app/health
|
||||||
### Health Checks
|
### Health Checks
|
||||||
|
|
||||||
**Application Health Check** (`/health`):
|
**Application Health Check** (`/health`):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
|
|
@ -846,27 +804,23 @@ curl https://banatie.app/health
|
||||||
```
|
```
|
||||||
|
|
||||||
**MinIO Health Check** (`/minio/health/live`):
|
**MinIO Health Check** (`/minio/health/live`):
|
||||||
|
|
||||||
- Returns 200 OK when MinIO is operational
|
- Returns 200 OK when MinIO is operational
|
||||||
- Used by Docker healthcheck and monitoring
|
- Used by Docker healthcheck and monitoring
|
||||||
|
|
||||||
### Log Management
|
### Log Management
|
||||||
|
|
||||||
**Application Logs**:
|
**Application Logs**:
|
||||||
|
|
||||||
- Location: `/opt/banatie/logs/`
|
- Location: `/opt/banatie/logs/`
|
||||||
- Format: Structured JSON
|
- Format: Structured JSON
|
||||||
- Rotation: Daily with 30-day retention
|
- Rotation: Daily with 30-day retention
|
||||||
|
|
||||||
**Access Logs**:
|
**Access Logs**:
|
||||||
|
|
||||||
- Caddy logs: `/opt/services/logs/banatie_*.log`
|
- Caddy logs: `/opt/services/logs/banatie_*.log`
|
||||||
- Format: JSON with request/response details
|
- Format: JSON with request/response details
|
||||||
|
|
||||||
### Backup Strategy
|
### Backup Strategy
|
||||||
|
|
||||||
**Database Backup**:
|
**Database Backup**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create backup
|
# Create backup
|
||||||
docker exec banatie-postgres pg_dump -U banatie_user banatie_db > banatie_db_backup.sql
|
docker exec banatie-postgres pg_dump -U banatie_user banatie_db > banatie_db_backup.sql
|
||||||
|
|
@ -876,7 +830,6 @@ docker exec -i banatie-postgres psql -U banatie_user banatie_db < banatie_db_bac
|
||||||
```
|
```
|
||||||
|
|
||||||
**MinIO Data Backup**:
|
**MinIO Data Backup**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backup MinIO data
|
# Backup MinIO data
|
||||||
sudo tar -czf banatie_minio_backup.tar.gz -C /opt/banatie/data minio/
|
sudo tar -czf banatie_minio_backup.tar.gz -C /opt/banatie/data minio/
|
||||||
|
|
@ -888,28 +841,24 @@ sudo tar -xzf banatie_minio_backup.tar.gz -C /opt/banatie/data
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
### Container Security
|
### Container Security
|
||||||
|
|
||||||
- Non-root users in containers
|
- Non-root users in containers
|
||||||
- Read-only root filesystems where possible
|
- Read-only root filesystems where possible
|
||||||
- Resource limits (memory, CPU)
|
- Resource limits (memory, CPU)
|
||||||
- Health checks for automatic restart
|
- Health checks for automatic restart
|
||||||
|
|
||||||
### Network Security
|
### Network Security
|
||||||
|
|
||||||
- Internal network isolation
|
- Internal network isolation
|
||||||
- No direct external access to database or MinIO
|
- No direct external access to database or MinIO
|
||||||
- Rate limiting at proxy level
|
- Rate limiting at proxy level
|
||||||
- HTTPS-only external access
|
- HTTPS-only external access
|
||||||
|
|
||||||
### Data Security
|
### Data Security
|
||||||
|
|
||||||
- Encrypted environment variables
|
- Encrypted environment variables
|
||||||
- Secure secret generation
|
- Secure secret generation
|
||||||
- Regular credential rotation
|
- Regular credential rotation
|
||||||
- Audit logging
|
- Audit logging
|
||||||
|
|
||||||
### Access Control
|
### Access Control
|
||||||
|
|
||||||
- MinIO bucket policies per organization
|
- MinIO bucket policies per organization
|
||||||
- PostgreSQL row-level security
|
- PostgreSQL row-level security
|
||||||
- JWT-based API authentication
|
- JWT-based API authentication
|
||||||
|
|
@ -918,14 +867,12 @@ sudo tar -xzf banatie_minio_backup.tar.gz -C /opt/banatie/data
|
||||||
## Future Scalability
|
## Future Scalability
|
||||||
|
|
||||||
### Horizontal Scaling Options
|
### Horizontal Scaling Options
|
||||||
|
|
||||||
- Multiple Banatie app containers behind load balancer
|
- Multiple Banatie app containers behind load balancer
|
||||||
- MinIO distributed mode for storage scaling
|
- MinIO distributed mode for storage scaling
|
||||||
- PostgreSQL read replicas for read scaling
|
- PostgreSQL read replicas for read scaling
|
||||||
- Redis for session storage and caching
|
- Redis for session storage and caching
|
||||||
|
|
||||||
### Multi-Region Deployment
|
### Multi-Region Deployment
|
||||||
|
|
||||||
- Regional MinIO clusters
|
- Regional MinIO clusters
|
||||||
- Database replication
|
- Database replication
|
||||||
- CDN integration for static assets
|
- CDN integration for static assets
|
||||||
|
|
@ -936,7 +883,6 @@ sudo tar -xzf banatie_minio_backup.tar.gz -C /opt/banatie/data
|
||||||
### Common Issues
|
### Common Issues
|
||||||
|
|
||||||
**Container Won't Start**:
|
**Container Won't Start**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check logs
|
# Check logs
|
||||||
docker-compose logs [service-name]
|
docker-compose logs [service-name]
|
||||||
|
|
@ -950,7 +896,6 @@ docker-compose up -d [service-name]
|
||||||
```
|
```
|
||||||
|
|
||||||
**Database Connection Issues**:
|
**Database Connection Issues**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Test database connectivity
|
# Test database connectivity
|
||||||
docker exec banatie-postgres pg_isready -U banatie_user -d banatie_db
|
docker exec banatie-postgres pg_isready -U banatie_user -d banatie_db
|
||||||
|
|
@ -963,7 +908,6 @@ docker-compose restart banatie-postgres
|
||||||
```
|
```
|
||||||
|
|
||||||
**MinIO Access Issues**:
|
**MinIO Access Issues**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check MinIO status
|
# Check MinIO status
|
||||||
docker exec banatie-minio mc admin info local
|
docker exec banatie-minio mc admin info local
|
||||||
|
|
@ -976,7 +920,6 @@ docker exec banatie-app env | grep MINIO_
|
||||||
```
|
```
|
||||||
|
|
||||||
**Network Connectivity**:
|
**Network Connectivity**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check networks
|
# Check networks
|
||||||
docker network ls
|
docker network ls
|
||||||
|
|
@ -991,4 +934,4 @@ docker exec banatie-app ping banatie-minio
|
||||||
|
|
||||||
**Document Version**: 1.0
|
**Document Version**: 1.0
|
||||||
**Last Updated**: 2024-09-26
|
**Last Updated**: 2024-09-26
|
||||||
**Maintained By**: Banatie Development Team
|
**Maintained By**: Banatie Development Team
|
||||||
|
|
@ -1,20 +1,17 @@
|
||||||
# MinIO Setup Technical Specification
|
# MinIO Setup Technical Specification
|
||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
|
|
||||||
Starting MinIO integration from scratch. Previous implementation had compatibility issues with bucket policies in SNSD mode. New implementation uses SNMD (Single Node Multi Drive) configuration with presigned URLs for reliable file access.
|
Starting MinIO integration from scratch. Previous implementation had compatibility issues with bucket policies in SNSD mode. New implementation uses SNMD (Single Node Multi Drive) configuration with presigned URLs for reliable file access.
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
### Storage Strategy
|
### Storage Strategy
|
||||||
|
|
||||||
- **Mode**: SNMD (4 virtual drives) for full S3 compatibility
|
- **Mode**: SNMD (4 virtual drives) for full S3 compatibility
|
||||||
- **Access Method**: Presigned URLs only (no bucket policies)
|
- **Access Method**: Presigned URLs only (no bucket policies)
|
||||||
- **Bucket Structure**: Single bucket `banatie` with path-based organization
|
- **Bucket Structure**: Single bucket `banatie` with path-based organization
|
||||||
- **File Organization**: `orgId/projectId/category/year-month/filename`
|
- **File Organization**: `orgId/projectId/category/year-month/filename`
|
||||||
|
|
||||||
### Technology Stack
|
### Technology Stack
|
||||||
|
|
||||||
- MinIO latest (`quay.io/minio/minio:latest`)
|
- MinIO latest (`quay.io/minio/minio:latest`)
|
||||||
- Docker Compose for orchestration
|
- Docker Compose for orchestration
|
||||||
- PostgreSQL for application data
|
- PostgreSQL for application data
|
||||||
|
|
@ -23,7 +20,6 @@ Starting MinIO integration from scratch. Previous implementation had compatibili
|
||||||
## Configuration Files Status
|
## Configuration Files Status
|
||||||
|
|
||||||
### Completed Files
|
### Completed Files
|
||||||
|
|
||||||
- `docker-compose.yml` - SNMD configuration with 4 virtual drives
|
- `docker-compose.yml` - SNMD configuration with 4 virtual drives
|
||||||
- `.env.docker` - Environment variables for development
|
- `.env.docker` - Environment variables for development
|
||||||
- `src/services/MinioStorageService.ts` - Updated service implementation
|
- `src/services/MinioStorageService.ts` - Updated service implementation
|
||||||
|
|
@ -33,7 +29,6 @@ Starting MinIO integration from scratch. Previous implementation had compatibili
|
||||||
### Integration Requirements
|
### Integration Requirements
|
||||||
|
|
||||||
#### 1. Update Application Router
|
#### 1. Update Application Router
|
||||||
|
|
||||||
Add images router to main application in `src/app.ts`:
|
Add images router to main application in `src/app.ts`:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
@ -44,7 +39,6 @@ app.use('/api', imagesRouter);
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2. Environment Variables Update
|
#### 2. Environment Variables Update
|
||||||
|
|
||||||
Update existing `.env` file with MinIO configuration:
|
Update existing `.env` file with MinIO configuration:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -66,23 +60,21 @@ PRESIGNED_URL_EXPIRY=86400
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 3. Database Script Update
|
#### 3. Database Script Update
|
||||||
|
|
||||||
Update `scripts/init-db.sql` to use new database name `banatie` instead of previous naming.
|
Update `scripts/init-db.sql` to use new database name `banatie` instead of previous naming.
|
||||||
|
|
||||||
#### 4. Service Dependencies Update
|
#### 4. Service Dependencies Update
|
||||||
|
|
||||||
Update existing image generation services to use new storage configuration:
|
Update existing image generation services to use new storage configuration:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// In ImageGenService.ts or similar
|
// In ImageGenService.ts or similar
|
||||||
const storageService = StorageFactory.getInstance();
|
const storageService = StorageFactory.getInstance();
|
||||||
const uploadResult = await storageService.uploadFile(
|
const uploadResult = await storageService.uploadFile(
|
||||||
orgId,
|
orgId,
|
||||||
projectId,
|
projectId,
|
||||||
'generated',
|
'generated',
|
||||||
filename,
|
filename,
|
||||||
buffer,
|
buffer,
|
||||||
'image/png',
|
'image/png'
|
||||||
);
|
);
|
||||||
// Use uploadResult.url (returns API URL for presigned access)
|
// Use uploadResult.url (returns API URL for presigned access)
|
||||||
```
|
```
|
||||||
|
|
@ -90,16 +82,13 @@ const uploadResult = await storageService.uploadFile(
|
||||||
## Setup Instructions
|
## Setup Instructions
|
||||||
|
|
||||||
### 1. Directory Structure
|
### 1. Directory Structure
|
||||||
|
|
||||||
Create required directories:
|
Create required directories:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p data/storage/{drive1,drive2,drive3,drive4}
|
mkdir -p data/storage/{drive1,drive2,drive3,drive4}
|
||||||
mkdir -p data/postgres
|
mkdir -p data/postgres
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Service Startup
|
### 2. Service Startup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start all services
|
# Start all services
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
|
|
@ -117,13 +106,11 @@ docker logs banatie-storage-init
|
||||||
### 3. Verification Steps
|
### 3. Verification Steps
|
||||||
|
|
||||||
#### MinIO Console Access
|
#### MinIO Console Access
|
||||||
|
|
||||||
- URL: http://localhost:9001
|
- URL: http://localhost:9001
|
||||||
- Username: banatie_admin
|
- Username: banatie_admin
|
||||||
- Password: banatie_storage_secure_key_2024
|
- Password: banatie_storage_secure_key_2024
|
||||||
|
|
||||||
#### Test Presigned URL Generation
|
#### Test Presigned URL Generation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Test image upload endpoint
|
# Test image upload endpoint
|
||||||
curl -X POST http://localhost:3000/api/upload \
|
curl -X POST http://localhost:3000/api/upload \
|
||||||
|
|
@ -136,7 +123,6 @@ curl -I "http://localhost:3000/api/images/default/main/generated/test-image.png"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Verify SNMD Mode
|
#### Verify SNMD Mode
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check MinIO is in erasure coding mode
|
# Check MinIO is in erasure coding mode
|
||||||
docker exec banatie-storage mc admin info local
|
docker exec banatie-storage mc admin info local
|
||||||
|
|
@ -146,7 +132,6 @@ docker exec banatie-storage mc admin info local
|
||||||
## Integration Testing
|
## Integration Testing
|
||||||
|
|
||||||
### Required Tests
|
### Required Tests
|
||||||
|
|
||||||
1. **Storage Service Initialization**
|
1. **Storage Service Initialization**
|
||||||
- Verify StorageFactory creates MinioStorageService
|
- Verify StorageFactory creates MinioStorageService
|
||||||
- Confirm bucket creation and accessibility
|
- Confirm bucket creation and accessibility
|
||||||
|
|
@ -167,7 +152,6 @@ docker exec banatie-storage mc admin info local
|
||||||
- Test MinIO connection failures
|
- Test MinIO connection failures
|
||||||
|
|
||||||
### Test Script Template
|
### Test Script Template
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# test-minio-integration.sh
|
# test-minio-integration.sh
|
||||||
|
|
@ -197,7 +181,6 @@ fi
|
||||||
## Production Considerations
|
## Production Considerations
|
||||||
|
|
||||||
### Environment Differences
|
### Environment Differences
|
||||||
|
|
||||||
Development and production use identical configuration with different values:
|
Development and production use identical configuration with different values:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -208,14 +191,12 @@ MINIO_USE_SSL=true
|
||||||
```
|
```
|
||||||
|
|
||||||
### Security Notes
|
### Security Notes
|
||||||
|
|
||||||
- All passwords use environment variables (no hardcoded values)
|
- All passwords use environment variables (no hardcoded values)
|
||||||
- Presigned URLs expire after 24 hours by default
|
- Presigned URLs expire after 24 hours by default
|
||||||
- Service user has minimal required permissions
|
- Service user has minimal required permissions
|
||||||
- MinIO admin access separate from application access
|
- MinIO admin access separate from application access
|
||||||
|
|
||||||
### Scaling Path
|
### Scaling Path
|
||||||
|
|
||||||
- Current SNMD setup supports development and small production loads
|
- Current SNMD setup supports development and small production loads
|
||||||
- Can migrate to distributed MinIO cluster when needed
|
- Can migrate to distributed MinIO cluster when needed
|
||||||
- Presigned URL architecture remains unchanged during scaling
|
- Presigned URL architecture remains unchanged during scaling
|
||||||
|
|
@ -223,13 +204,11 @@ MINIO_USE_SSL=true
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Common Issues
|
### Common Issues
|
||||||
|
|
||||||
1. **403 Errors**: Check presigned URL generation and MinIO service user permissions
|
1. **403 Errors**: Check presigned URL generation and MinIO service user permissions
|
||||||
2. **404 Errors**: Verify file paths and bucket configuration
|
2. **404 Errors**: Verify file paths and bucket configuration
|
||||||
3. **Connection Errors**: Confirm MinIO service health and network connectivity
|
3. **Connection Errors**: Confirm MinIO service health and network connectivity
|
||||||
|
|
||||||
### Debug Commands
|
### Debug Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check MinIO health
|
# Check MinIO health
|
||||||
docker exec banatie-storage mc admin info local
|
docker exec banatie-storage mc admin info local
|
||||||
|
|
@ -243,7 +222,6 @@ docker logs banatie-app
|
||||||
```
|
```
|
||||||
|
|
||||||
### Recovery Procedures
|
### Recovery Procedures
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Reset MinIO data (development only)
|
# Reset MinIO data (development only)
|
||||||
docker-compose down
|
docker-compose down
|
||||||
|
|
@ -257,19 +235,16 @@ docker exec banatie-storage mc mb storage/banatie
|
||||||
## Implementation Priority
|
## Implementation Priority
|
||||||
|
|
||||||
### Phase 1 (Immediate)
|
### Phase 1 (Immediate)
|
||||||
|
|
||||||
1. Update app.ts with images router
|
1. Update app.ts with images router
|
||||||
2. Update environment configuration
|
2. Update environment configuration
|
||||||
3. Test basic upload/download functionality
|
3. Test basic upload/download functionality
|
||||||
|
|
||||||
### Phase 2 (Next)
|
### Phase 2 (Next)
|
||||||
|
|
||||||
1. Update existing image generation services
|
1. Update existing image generation services
|
||||||
2. Implement comprehensive error handling
|
2. Implement comprehensive error handling
|
||||||
3. Add integration tests
|
3. Add integration tests
|
||||||
|
|
||||||
### Phase 3 (Future)
|
### Phase 3 (Future)
|
||||||
|
|
||||||
1. Add monitoring and logging
|
1. Add monitoring and logging
|
||||||
2. Implement file cleanup policies
|
2. Implement file cleanup policies
|
||||||
3. Add CDN integration capability
|
3. Add CDN integration capability
|
||||||
|
|
@ -277,7 +252,6 @@ docker exec banatie-storage mc mb storage/banatie
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
Integration is complete when:
|
Integration is complete when:
|
||||||
|
|
||||||
- [ ] All services start successfully via docker-compose
|
- [ ] All services start successfully via docker-compose
|
||||||
- [ ] MinIO operates in SNMD mode with 4 drives
|
- [ ] MinIO operates in SNMD mode with 4 drives
|
||||||
- [ ] Image upload returns API URL format
|
- [ ] Image upload returns API URL format
|
||||||
|
|
@ -288,4 +262,4 @@ Integration is complete when:
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
This implementation prioritizes simplicity and reliability over advanced features. The presigned URL approach ensures consistent behavior across different MinIO configurations and provides a foundation for future enhancements without requiring architectural changes.
|
This implementation prioritizes simplicity and reliability over advanced features. The presigned URL approach ensures consistent behavior across different MinIO configurations and provides a foundation for future enhancements without requiring architectural changes.
|
||||||
18
package.json
18
package.json
|
|
@ -3,7 +3,6 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Banatie AI Image Generation Service - Monorepo with API, Landing, Studio, and Admin apps",
|
"description": "Banatie AI Image Generation Service - Monorepo with API, Landing, Studio, and Admin apps",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
|
||||||
"packageManager": "pnpm@10.11.0",
|
"packageManager": "pnpm@10.11.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0",
|
"node": ">=18.0.0",
|
||||||
|
|
@ -34,13 +33,7 @@
|
||||||
"typecheck:landing": "pnpm --filter @banatie/landing typecheck",
|
"typecheck:landing": "pnpm --filter @banatie/landing typecheck",
|
||||||
"typecheck:studio": "pnpm --filter @banatie/studio typecheck",
|
"typecheck:studio": "pnpm --filter @banatie/studio typecheck",
|
||||||
"typecheck:admin": "pnpm --filter @banatie/admin typecheck",
|
"typecheck:admin": "pnpm --filter @banatie/admin typecheck",
|
||||||
"test": "vitest",
|
"test": "pnpm --filter @banatie/api-service test",
|
||||||
"test:ui": "vitest --ui",
|
|
||||||
"test:run": "vitest run",
|
|
||||||
"test:coverage": "vitest run --coverage",
|
|
||||||
"test:api": "pnpm --filter @banatie/api-service test",
|
|
||||||
"format": "prettier --write \"apps/**/*.{ts,tsx,js,jsx,json,css,md}\" \"packages/**/*.{ts,tsx,js,jsx,json,css,md}\" \"*.{ts,tsx,js,jsx,json,css,md}\" --ignore-unknown",
|
|
||||||
"format:check": "prettier --check \"apps/**/*.{ts,tsx,js,jsx,json,css,md}\" \"packages/**/*.{ts,tsx,js,jsx,json,css,md}\" \"*.{ts,tsx,js,jsx,json,css,md}\" --ignore-unknown",
|
|
||||||
"clean": "pnpm -r clean && rm -rf node_modules",
|
"clean": "pnpm -r clean && rm -rf node_modules",
|
||||||
"install:all": "pnpm install"
|
"install:all": "pnpm install"
|
||||||
},
|
},
|
||||||
|
|
@ -57,12 +50,7 @@
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitest/ui": "^3.2.4",
|
|
||||||
"eslint-config-prettier": "^9.1.2",
|
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
|
||||||
"kill-port": "^2.0.1",
|
"kill-port": "^2.0.1",
|
||||||
"prettier": "^3.6.2",
|
"typescript": "^5.9.2"
|
||||||
"typescript": "^5.9.2",
|
|
||||||
"vitest": "^3.2.4"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5,8 +5,6 @@ export default {
|
||||||
out: './migrations',
|
out: './migrations',
|
||||||
dialect: 'postgresql',
|
dialect: 'postgresql',
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url:
|
url: process.env.DATABASE_URL || 'postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db',
|
||||||
process.env.DATABASE_URL ||
|
|
||||||
'postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db',
|
|
||||||
},
|
},
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|
@ -55,12 +55,16 @@
|
||||||
"organizations_slug_unique": {
|
"organizations_slug_unique": {
|
||||||
"name": "organizations_slug_unique",
|
"name": "organizations_slug_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": ["slug"]
|
"columns": [
|
||||||
|
"slug"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"organizations_email_unique": {
|
"organizations_email_unique": {
|
||||||
"name": "organizations_email_unique",
|
"name": "organizations_email_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": ["email"]
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
|
|
@ -117,8 +121,12 @@
|
||||||
"name": "projects_organization_id_organizations_id_fk",
|
"name": "projects_organization_id_organizations_id_fk",
|
||||||
"tableFrom": "projects",
|
"tableFrom": "projects",
|
||||||
"tableTo": "organizations",
|
"tableTo": "organizations",
|
||||||
"columnsFrom": ["organization_id"],
|
"columnsFrom": [
|
||||||
"columnsTo": ["id"],
|
"organization_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -128,7 +136,10 @@
|
||||||
"projects_organization_id_slug_unique": {
|
"projects_organization_id_slug_unique": {
|
||||||
"name": "projects_organization_id_slug_unique",
|
"name": "projects_organization_id_slug_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": ["organization_id", "slug"]
|
"columns": [
|
||||||
|
"organization_id",
|
||||||
|
"slug"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
|
|
@ -229,8 +240,12 @@
|
||||||
"name": "api_keys_organization_id_organizations_id_fk",
|
"name": "api_keys_organization_id_organizations_id_fk",
|
||||||
"tableFrom": "api_keys",
|
"tableFrom": "api_keys",
|
||||||
"tableTo": "organizations",
|
"tableTo": "organizations",
|
||||||
"columnsFrom": ["organization_id"],
|
"columnsFrom": [
|
||||||
"columnsTo": ["id"],
|
"organization_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
|
|
@ -238,8 +253,12 @@
|
||||||
"name": "api_keys_project_id_projects_id_fk",
|
"name": "api_keys_project_id_projects_id_fk",
|
||||||
"tableFrom": "api_keys",
|
"tableFrom": "api_keys",
|
||||||
"tableTo": "projects",
|
"tableTo": "projects",
|
||||||
"columnsFrom": ["project_id"],
|
"columnsFrom": [
|
||||||
"columnsTo": ["id"],
|
"project_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -249,7 +268,9 @@
|
||||||
"api_keys_key_hash_unique": {
|
"api_keys_key_hash_unique": {
|
||||||
"name": "api_keys_key_hash_unique",
|
"name": "api_keys_key_hash_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": ["key_hash"]
|
"columns": [
|
||||||
|
"key_hash"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
|
|
@ -268,4 +289,4 @@
|
||||||
"schemas": {},
|
"schemas": {},
|
||||||
"tables": {}
|
"tables": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -10,4 +10,4 @@
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue