feat: minio integration

This commit is contained in:
Oleg Proskurin 2025-09-28 22:43:06 +07:00
parent 2c7eb7c090
commit 5c8bc90bcc
10 changed files with 321 additions and 44 deletions

1
.gitignore vendored
View File

@ -72,6 +72,7 @@ jspm_packages/
.env
# Generated images and uploads
data/storage/
results/
uploads/

View File

@ -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)

View File

@ -1,4 +1,3 @@
version: '3.8'
services:
app:

129
scripts/init-db.sql Normal file
View File

@ -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;

View File

@ -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);

View File

@ -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,

View File

@ -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<ImageGenerationResult> {
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<ImageGenerationResult> {
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<void> {
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

View File

@ -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
};
}

View File

@ -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<void>;
/**
* Check if the main bucket exists
*/
bucketExists(): Promise<boolean>;
/**
* 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<UploadResult>;
/**
* 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<Buffer>;
/**
* 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<string>;
/**
* 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<string>;
/**
* 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<FileMetadata[]>;
/**
* 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<void>;
/**
* 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<boolean>;
}

View File

@ -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;