From 5c8bc90bcc52b558fd8dc52b3fcae6f2713f84bc Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Sun, 28 Sep 2025 22:43:06 +0700 Subject: [PATCH] feat: minio integration --- .gitignore | 1 + CLAUDE.md | 2 + docker-compose.yml | 1 - scripts/init-db.sql | 129 ++++++++++++++++++++++++++ src/app.ts | 2 + src/routes/textToImage.ts | 1 + src/services/ImageGenService.ts | 78 ++++++++-------- src/services/MinioStorageService.ts | 8 +- src/services/StorageService.ts | 138 ++++++++++++++++++++++++++++ src/types/api.ts | 5 + 10 files changed, 321 insertions(+), 44 deletions(-) create mode 100644 scripts/init-db.sql create mode 100644 src/services/StorageService.ts diff --git a/.gitignore b/.gitignore index 0358825..1577034 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,7 @@ jspm_packages/ .env # Generated images and uploads +data/storage/ results/ uploads/ diff --git a/CLAUDE.md b/CLAUDE.md index ca66458..256c118 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,8 @@ Banatie is a REST API service for AI-powered image generation using the Gemini F ## Development Commands +use `docker compose` command for using docker-compose service (v3 version) + ### Core Development - `pnpm dev` - Start development server with auto-reload using tsx - `pnpm start` - Start production server (runs build first) diff --git a/docker-compose.yml b/docker-compose.yml index e08851b..d258d64 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3.8' services: app: diff --git a/scripts/init-db.sql b/scripts/init-db.sql new file mode 100644 index 0000000..1109114 --- /dev/null +++ b/scripts/init-db.sql @@ -0,0 +1,129 @@ +-- Banatie Database Initialization Script +-- This script creates the initial database schema for the Banatie image generation service + +-- Enable UUID extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Organizations table +CREATE TABLE IF NOT EXISTS organizations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) UNIQUE NOT NULL, + description TEXT, + settings JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Projects table (within organizations) +CREATE TABLE IF NOT EXISTS projects ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) NOT NULL, + description TEXT, + settings JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(organization_id, slug) +); + +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE, + username VARCHAR(100) NOT NULL, + email VARCHAR(255), + role VARCHAR(50) DEFAULT 'user', + settings JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(organization_id, username) +); + +-- Image metadata table +CREATE TABLE IF NOT EXISTS images ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE, + project_id UUID REFERENCES projects(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + filename VARCHAR(255) NOT NULL, + original_filename VARCHAR(255), + file_path VARCHAR(500) NOT NULL, -- Path in MinIO + category VARCHAR(50) NOT NULL CHECK (category IN ('uploads', 'generated', 'references')), + original_prompt TEXT, + enhanced_prompt TEXT, + model_used VARCHAR(100), + file_size BIGINT, + content_type VARCHAR(100), + metadata JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Upload sessions table (for tracking multi-part uploads) +CREATE TABLE IF NOT EXISTS upload_sessions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE, + project_id UUID REFERENCES projects(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + session_data JSONB NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Create indexes for better performance +CREATE INDEX IF NOT EXISTS idx_organizations_slug ON organizations(slug); +CREATE INDEX IF NOT EXISTS idx_projects_org_id ON projects(organization_id); +CREATE INDEX IF NOT EXISTS idx_projects_org_slug ON projects(organization_id, slug); +CREATE INDEX IF NOT EXISTS idx_users_org_id ON users(organization_id); +CREATE INDEX IF NOT EXISTS idx_users_org_username ON users(organization_id, username); +CREATE INDEX IF NOT EXISTS idx_images_org_id ON images(organization_id); +CREATE INDEX IF NOT EXISTS idx_images_project_id ON images(project_id); +CREATE INDEX IF NOT EXISTS idx_images_user_id ON images(user_id); +CREATE INDEX IF NOT EXISTS idx_images_category ON images(category); +CREATE INDEX IF NOT EXISTS idx_images_created_at ON images(created_at); +CREATE INDEX IF NOT EXISTS idx_upload_sessions_user_id ON upload_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_upload_sessions_expires_at ON upload_sessions(expires_at); + +-- Function to update the updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Create triggers to automatically update updated_at +CREATE TRIGGER update_organizations_updated_at BEFORE UPDATE ON organizations FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_projects_updated_at BEFORE UPDATE ON projects FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_images_updated_at BEFORE UPDATE ON images FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Insert default organization and project +INSERT INTO organizations (id, name, slug, description) VALUES + ('00000000-0000-0000-0000-000000000001', 'Default Organization', 'default', 'Default organization for development and testing') +ON CONFLICT (slug) DO NOTHING; + +INSERT INTO projects (id, organization_id, name, slug, description) VALUES + ('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001', 'Main Project', 'main', 'Main project for image generation') +ON CONFLICT (organization_id, slug) DO NOTHING; + +-- Insert system user +INSERT INTO users (id, organization_id, username, role) VALUES + ('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001', 'system', 'admin') +ON CONFLICT (organization_id, username) DO NOTHING; + +-- Insert demo organization for development +INSERT INTO organizations (id, name, slug, description) VALUES + ('00000000-0000-0000-0000-000000000002', 'Demo Organization', 'demo', 'Demo organization for testing and development') +ON CONFLICT (slug) DO NOTHING; + +INSERT INTO projects (id, organization_id, name, slug, description) VALUES + ('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000002', 'Sandbox Project', 'sandbox', 'Sandbox project for testing features') +ON CONFLICT (organization_id, slug) DO NOTHING; + +INSERT INTO users (id, organization_id, username, role) VALUES + ('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000002', 'guest', 'user') +ON CONFLICT (organization_id, username) DO NOTHING; \ No newline at end of file diff --git a/src/app.ts b/src/app.ts index 5a8b77f..955af40 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,6 +5,7 @@ import { Config } from './types/api'; import { generateRouter } from './routes/generate'; import { enhanceRouter } from './routes/enhance'; import { textToImageRouter } from './routes/textToImage'; +import { imagesRouter } from './routes/images'; import { errorHandler, notFoundHandler } from './middleware/errorHandler'; // Load environment variables @@ -82,6 +83,7 @@ export const createApp = (): Application => { app.use('/api', generateRouter); app.use('/api', enhanceRouter); app.use('/api', textToImageRouter); + app.use('/api', imagesRouter); // Error handling middleware (must be last) app.use(notFoundHandler); diff --git a/src/routes/textToImage.ts b/src/routes/textToImage.ts index 77d30c2..0a44896 100644 --- a/src/routes/textToImage.ts +++ b/src/routes/textToImage.ts @@ -82,6 +82,7 @@ textToImageRouter.post( data: { filename: result.filename!, filepath: result.filepath!, + ...(result.url && { url: result.url }), ...(result.description && { description: result.description }), model: result.model, generatedAt: timestamp, diff --git a/src/services/ImageGenService.ts b/src/services/ImageGenService.ts index a4a9845..0ca8393 100644 --- a/src/services/ImageGenService.ts +++ b/src/services/ImageGenService.ts @@ -1,13 +1,13 @@ import { GoogleGenAI } from "@google/genai"; // eslint-disable-next-line @typescript-eslint/no-var-requires const mime = require("mime") as any; -import fs from "fs"; import path from "path"; import { ImageGenerationOptions, ImageGenerationResult, ReferenceImage, } from "../types/api"; +import { StorageFactory } from "./StorageFactory"; export class ImageGenService { private ai: GoogleGenAI; @@ -27,11 +27,16 @@ export class ImageGenService { async generateImage( options: ImageGenerationOptions, ): Promise { - const { prompt, filename, referenceImages } = options; + const { prompt, filename, referenceImages, orgId, projectId, userId } = options; const timestamp = new Date().toISOString(); + // Use default values if not provided + const finalOrgId = orgId || process.env['DEFAULT_ORG_ID'] || 'default'; + const finalProjectId = projectId || process.env['DEFAULT_PROJECT_ID'] || 'main'; + const finalUserId = userId || process.env['DEFAULT_USER_ID'] || 'system'; + console.log( - `[${timestamp}] Starting image generation: "${prompt.substring(0, 50)}..."`, + `[${timestamp}] Starting image generation: "${prompt.substring(0, 50)}..." for ${finalOrgId}/${finalProjectId}`, ); try { @@ -41,6 +46,9 @@ export class ImageGenService { config: { responseModalities: ["IMAGE", "TEXT"] }, prompt, filename, + orgId: finalOrgId, + projectId: finalProjectId, + userId: finalUserId, ...(referenceImages && { referenceImages }), modelName: "Nano Banana", }); @@ -59,6 +67,9 @@ export class ImageGenService { config: { responseModalities: ["IMAGE"] }, prompt, filename: `${filename}_fallback`, + orgId: finalOrgId, + projectId: finalProjectId, + userId: finalUserId, ...(referenceImages && { referenceImages }), modelName: "Imagen 4", }); @@ -84,10 +95,13 @@ export class ImageGenService { config: { responseModalities: string[] }; prompt: string; filename: string; + orgId: string; + projectId: string; + userId: string; referenceImages?: ReferenceImage[]; modelName: string; }): Promise { - const { model, config, prompt, filename, referenceImages, modelName } = + const { model, config, prompt, filename, orgId, projectId, userId, referenceImages, modelName } = params; try { @@ -143,7 +157,7 @@ export class ImageGenService { ) { const content = response.candidates[0].content; let generatedDescription = ""; - let savedImagePath = ""; + let uploadResult = null; for (let index = 0; index < (content.parts?.length || 0); index++) { const part = content.parts?.[index]; @@ -154,16 +168,28 @@ export class ImageGenService { part.inlineData.mimeType || "", ); const finalFilename = `${filename}.${fileExtension}`; - const filepath = path.join("./results", finalFilename); + const contentType = part.inlineData.mimeType || `image/${fileExtension}`; console.log( - `[${new Date().toISOString()}] Saving image: ${finalFilename}`, + `[${new Date().toISOString()}] Uploading image to MinIO: ${finalFilename}`, ); const buffer = Buffer.from(part.inlineData.data || "", "base64"); - await this.saveImageFile(filepath, buffer); - savedImagePath = filepath; + // Upload to MinIO storage + const storageService = StorageFactory.getInstance(); + uploadResult = await storageService.uploadFile( + orgId, + projectId, + 'generated', + finalFilename, + buffer, + contentType + ); + + console.log( + `[${new Date().toISOString()}] Image uploaded successfully: ${uploadResult.path}`, + ); } else if (part.text) { generatedDescription = part.text; console.log( @@ -172,11 +198,12 @@ export class ImageGenService { } } - if (savedImagePath) { + if (uploadResult && uploadResult.success) { return { success: true, - filename: path.basename(savedImagePath), - filepath: savedImagePath, + filename: uploadResult.filename, + filepath: uploadResult.path, + url: uploadResult.url, description: generatedDescription, model: modelName, }; @@ -201,33 +228,6 @@ export class ImageGenService { } } - /** - * Save image buffer to file system - */ - private async saveImageFile(filepath: string, buffer: Buffer): Promise { - return new Promise((resolve, reject) => { - // Ensure the results directory exists - const dir = path.dirname(filepath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - - fs.writeFile(filepath, buffer, (err) => { - if (err) { - console.error( - `[${new Date().toISOString()}] Error saving file ${filepath}:`, - err, - ); - reject(err); - } else { - console.log( - `[${new Date().toISOString()}] File saved successfully: ${filepath}`, - ); - resolve(); - } - }); - }); - } /** * Validate reference images diff --git a/src/services/MinioStorageService.ts b/src/services/MinioStorageService.ts index 987cd14..97622da 100644 --- a/src/services/MinioStorageService.ts +++ b/src/services/MinioStorageService.ts @@ -109,17 +109,17 @@ export class MinioStorageService implements StorageService { metadata ); - const key = `${this.bucketName}/${filePath}`; const url = this.getPublicUrl(orgId, projectId, category, uniqueFilename); console.log(`Generated API URL: ${url}`); return { - key, + success: true, filename: uniqueFilename, + path: filePath, url, - etag: result.etag, - size: buffer.length + size: buffer.length, + contentType }; } diff --git a/src/services/StorageService.ts b/src/services/StorageService.ts new file mode 100644 index 0000000..a69f21d --- /dev/null +++ b/src/services/StorageService.ts @@ -0,0 +1,138 @@ +export interface FileMetadata { + filename: string; + size: number; + contentType: string; + lastModified: Date; + etag?: string; + path: string; +} + +export interface UploadResult { + success: boolean; + filename: string; + path: string; + url: string; // API URL for accessing the file + size: number; + contentType: string; + error?: string; +} + +export interface StorageService { + /** + * Create the main bucket if it doesn't exist + */ + createBucket(): Promise; + + /** + * Check if the main bucket exists + */ + bucketExists(): Promise; + + /** + * Upload a file to storage + * @param orgId Organization ID + * @param projectId Project ID + * @param category File category (uploads, generated, references) + * @param filename Original filename + * @param buffer File buffer + * @param contentType MIME type + */ + uploadFile( + orgId: string, + projectId: string, + category: 'uploads' | 'generated' | 'references', + filename: string, + buffer: Buffer, + contentType: string + ): Promise; + + /** + * Download a file from storage + * @param orgId Organization ID + * @param projectId Project ID + * @param category File category + * @param filename Filename to download + */ + downloadFile( + orgId: string, + projectId: string, + category: 'uploads' | 'generated' | 'references', + filename: string + ): Promise; + + /** + * Generate a presigned URL for downloading a file + * @param orgId Organization ID + * @param projectId Project ID + * @param category File category + * @param filename Filename + * @param expirySeconds URL expiry time in seconds + */ + getPresignedDownloadUrl( + orgId: string, + projectId: string, + category: 'uploads' | 'generated' | 'references', + filename: string, + expirySeconds: number + ): Promise; + + /** + * Generate a presigned URL for uploading a file + * @param orgId Organization ID + * @param projectId Project ID + * @param category File category + * @param filename Filename + * @param expirySeconds URL expiry time in seconds + * @param contentType MIME type + */ + getPresignedUploadUrl( + orgId: string, + projectId: string, + category: 'uploads' | 'generated' | 'references', + filename: string, + expirySeconds: number, + contentType: string + ): Promise; + + /** + * List files in a specific path + * @param orgId Organization ID + * @param projectId Project ID + * @param category File category + * @param prefix Optional prefix to filter files + */ + listFiles( + orgId: string, + projectId: string, + category: 'uploads' | 'generated' | 'references', + prefix?: string + ): Promise; + + /** + * Delete a file from storage + * @param orgId Organization ID + * @param projectId Project ID + * @param category File category + * @param filename Filename to delete + */ + deleteFile( + orgId: string, + projectId: string, + category: 'uploads' | 'generated' | 'references', + filename: string + ): Promise; + + /** + * Check if a file exists + * @param orgId Organization ID + * @param projectId Project ID + * @param category File category + * @param filename Filename to check + */ + fileExists( + orgId: string, + projectId: string, + category: 'uploads' | 'generated' | 'references', + filename: string + ): Promise; +} \ No newline at end of file diff --git a/src/types/api.ts b/src/types/api.ts index f40eca4..d9d3a68 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -32,6 +32,7 @@ export interface GenerateImageResponse { data?: { filename: string; filepath: string; + url?: string; // API URL for accessing the image description?: string; model: string; generatedAt: string; @@ -57,6 +58,9 @@ export interface ImageGenerationOptions { prompt: string; filename: string; referenceImages?: ReferenceImage[]; + orgId?: string; + projectId?: string; + userId?: string; } export interface ReferenceImage { @@ -69,6 +73,7 @@ export interface ImageGenerationResult { success: boolean; filename?: string; filepath?: string; + url?: string; // API URL for accessing the image description?: string; model: string; error?: string;