This commit is contained in:
Oleg Proskurin 2025-12-14 18:11:20 +07:00
parent 35d28bca80
commit d798faec41
14 changed files with 100 additions and 49 deletions

View File

@ -1,4 +1,4 @@
import { createDbClient } from '@banatie/database'; import { createDbClient, type DbClient } from '@banatie/database';
import { config } from 'dotenv'; import { config } from 'dotenv';
import path from 'path'; import path from 'path';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
@ -20,7 +20,7 @@ const DATABASE_URL =
process.env['DATABASE_URL'] || process.env['DATABASE_URL'] ||
'postgresql://banatie_user:banatie_secure_password@localhost:5460/banatie_db'; 'postgresql://banatie_user:banatie_secure_password@localhost:5460/banatie_db';
export const db = createDbClient(DATABASE_URL); export const db: DbClient = 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}`,

View File

@ -6,10 +6,11 @@ import { Request, Response, NextFunction } from 'express';
*/ */
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) {
return res.status(401).json({ res.status(401).json({
error: 'Authentication required', error: 'Authentication required',
message: 'This endpoint requires authentication', message: 'This endpoint requires authentication',
}); });
return;
} }
if (req.apiKey.keyType !== 'master') { if (req.apiKey.keyType !== 'master') {
@ -17,10 +18,11 @@ export function requireMasterKey(req: Request, res: Response, next: NextFunction
`[${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}`,
); );
return res.status(403).json({ res.status(403).json({
error: 'Master key required', error: 'Master key required',
message: 'This endpoint requires a master API key', message: 'This endpoint requires a master API key',
}); });
return;
} }
next(); next();

View File

@ -7,27 +7,30 @@ import { Request, Response, NextFunction } from 'express';
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) {
return res.status(401).json({ res.status(401).json({
error: 'Authentication required', error: 'Authentication required',
message: 'API key validation must be performed first', message: 'API key validation must be performed first',
}); });
return;
} }
// Block master keys from generation endpoints // Block master keys from generation endpoints
if (req.apiKey.keyType === 'master') { if (req.apiKey.keyType === 'master') {
return 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;
} }
// Ensure project key has required IDs // Ensure project key has required IDs
if (!req.apiKey.projectId) { if (!req.apiKey.projectId) {
return res.status(400).json({ res.status(400).json({
error: 'Invalid API key', error: 'Invalid API key',
message: 'Project key must be associated with a project', message: 'Project key must be associated with a project',
}); });
return;
} }
console.log( console.log(

View File

@ -23,20 +23,22 @@ export async function validateApiKey(
const providedKey = req.headers['x-api-key'] as string; const providedKey = req.headers['x-api-key'] as string;
if (!providedKey) { if (!providedKey) {
return res.status(401).json({ res.status(401).json({
error: 'Missing API key', error: 'Missing API key',
message: 'Provide your API key via X-API-Key header', message: 'Provide your API key via X-API-Key header',
}); });
return;
} }
try { try {
const apiKey = await apiKeyService.validateKey(providedKey); const apiKey = await apiKeyService.validateKey(providedKey);
if (!apiKey) { if (!apiKey) {
return res.status(401).json({ res.status(401).json({
error: 'Invalid API key', error: 'Invalid API key',
message: 'The provided API key is invalid, expired, or revoked', message: 'The provided API key is invalid, expired, or revoked',
}); });
return;
} }
// Attach to request for use in routes // Attach to request for use in routes

View File

@ -1,9 +1,9 @@
import express from 'express'; import express, { Router } from 'express';
import { ApiKeyService } from '../../services/ApiKeyService'; import { ApiKeyService } from '../../services/ApiKeyService';
import { validateApiKey } from '../../middleware/auth/validateApiKey'; import { validateApiKey } from '../../middleware/auth/validateApiKey';
import { requireMasterKey } from '../../middleware/auth/requireMasterKey'; import { requireMasterKey } from '../../middleware/auth/requireMasterKey';
const router = express.Router(); const router: Router = express.Router();
const apiKeyService = new ApiKeyService(); const apiKeyService = new ApiKeyService();
// All admin routes require master key // All admin routes require master key
@ -14,12 +14,12 @@ router.use(requireMasterKey);
* Create a new API key * Create a new API key
* POST /api/admin/keys * POST /api/admin/keys
*/ */
router.post('/', async (req, res) => { router.post('/', async (req, res): Promise<void> => {
try { try {
const { const {
type, type,
projectId, projectId: _projectId,
organizationId, organizationId: _organizationId,
organizationSlug, organizationSlug,
projectSlug, projectSlug,
organizationName, organizationName,
@ -30,24 +30,27 @@ router.post('/', async (req, res) => {
// Validation // Validation
if (!type || !['master', 'project'].includes(type)) { if (!type || !['master', 'project'].includes(type)) {
return res.status(400).json({ res.status(400).json({
error: 'Invalid type', error: 'Invalid type',
message: 'Type must be either "master" or "project"', message: 'Type must be either "master" or "project"',
}); });
return;
} }
if (type === 'project' && !projectSlug) { if (type === 'project' && !projectSlug) {
return res.status(400).json({ res.status(400).json({
error: 'Missing projectSlug', error: 'Missing projectSlug',
message: 'Project keys require a projectSlug', message: 'Project keys require a projectSlug',
}); });
return;
} }
if (type === 'project' && !organizationSlug) { if (type === 'project' && !organizationSlug) {
return res.status(400).json({ res.status(400).json({
error: 'Missing organizationSlug', error: 'Missing organizationSlug',
message: 'Project keys require an organizationSlug', message: 'Project keys require an organizationSlug',
}); });
return;
} }
// Create key // Create key
@ -148,17 +151,18 @@ router.get('/', async (req, res) => {
* Revoke an API key * Revoke an API key
* DELETE /api/admin/keys/:keyId * DELETE /api/admin/keys/:keyId
*/ */
router.delete('/:keyId', async (req, res) => { router.delete('/:keyId', async (req, res): Promise<void> => {
try { try {
const { keyId } = req.params; const { keyId } = req.params;
const success = await apiKeyService.revokeKey(keyId); const success = await apiKeyService.revokeKey(keyId);
if (!success) { if (!success) {
return res.status(404).json({ res.status(404).json({
error: 'Key not found', error: 'Key not found',
message: 'The specified API key does not exist', message: 'The specified API key does not exist',
}); });
return;
} }
console.log(`[${new Date().toISOString()}] API key revoked: ${keyId} - by: ${req.apiKey!.id}`); console.log(`[${new Date().toISOString()}] API key revoked: ${keyId} - by: ${req.apiKey!.id}`);

View File

@ -1,7 +1,7 @@
import express from 'express'; import express, { Router } from 'express';
import { ApiKeyService } from '../services/ApiKeyService'; import { ApiKeyService } from '../services/ApiKeyService';
const router = express.Router(); const router: Router = express.Router();
const apiKeyService = new ApiKeyService(); const apiKeyService = new ApiKeyService();
/** /**
@ -10,17 +10,18 @@ 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): Promise<void> => {
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({ 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.',
}); });
return;
} }
// Create first master key // Create first master key

View File

@ -1,11 +1,12 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import type { Router as RouterType } from 'express';
import { StorageFactory } from '../services/StorageFactory'; import { StorageFactory } from '../services/StorageFactory';
import { asyncHandler } from '../middleware/errorHandler'; import { asyncHandler } from '../middleware/errorHandler';
import { validateApiKey } from '../middleware/auth/validateApiKey'; import { validateApiKey } from '../middleware/auth/validateApiKey';
import { requireProjectKey } from '../middleware/auth/requireProjectKey'; import { requireProjectKey } from '../middleware/auth/requireProjectKey';
import { rateLimitByApiKey } from '../middleware/auth/rateLimiter'; import { rateLimitByApiKey } from '../middleware/auth/rateLimiter';
export const imagesRouter = Router(); export const imagesRouter: RouterType = Router();
/** /**
* GET /api/images/:orgId/:projectId/:category/:filename * GET /api/images/:orgId/:projectId/:category/:filename
@ -13,15 +14,25 @@ export const imagesRouter = Router();
*/ */
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): Promise<void> => {
const { orgId, projectId, category, filename } = req.params; const { orgId, projectId, category, filename } = req.params;
// Validate required params (these are guaranteed by route pattern)
if (!orgId || !projectId || !category || !filename) {
res.status(400).json({
success: false,
message: 'Missing required parameters',
});
return;
}
// Validate category // Validate category
if (!['uploads', 'generated', 'references'].includes(category)) { if (!['uploads', 'generated', 'references'].includes(category)) {
return res.status(400).json({ res.status(400).json({
success: false, success: false,
message: 'Invalid category', message: 'Invalid category',
}); });
return;
} }
const storageService = await StorageFactory.getInstance(); const storageService = await StorageFactory.getInstance();
@ -36,10 +47,11 @@ imagesRouter.get(
); );
if (!exists) { if (!exists) {
return res.status(404).json({ res.status(404).json({
success: false, success: false,
message: 'File not found', message: 'File not found',
}); });
return;
} }
// Determine content type from filename // Determine content type from filename
@ -62,7 +74,8 @@ imagesRouter.get(
// 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 res.status(304).end(); // Not Modified
return;
} }
// Stream the file directly through our API (memory efficient) // Stream the file directly through our API (memory efficient)
@ -88,7 +101,7 @@ imagesRouter.get(
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({ res.status(404).json({
success: false, success: false,
message: 'File not found', message: 'File not found',
}); });
@ -102,15 +115,25 @@ imagesRouter.get(
*/ */
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): Promise<void> => {
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
// Validate required params (these are guaranteed by route pattern)
if (!orgId || !projectId || !category || !filename) {
res.status(400).json({
success: false,
message: 'Missing required parameters',
});
return;
}
if (!['uploads', 'generated', 'references'].includes(category)) { if (!['uploads', 'generated', 'references'].includes(category)) {
return res.status(400).json({ res.status(400).json({
success: false, success: false,
message: 'Invalid category', message: 'Invalid category',
}); });
return;
} }
const storageService = await StorageFactory.getInstance(); const storageService = await StorageFactory.getInstance();
@ -124,14 +147,14 @@ imagesRouter.get(
parseInt(expiry as string, 10), parseInt(expiry as string, 10),
); );
return res.json({ res.json({
success: true, success: true,
url: presignedUrl, url: presignedUrl,
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({ res.status(404).json({
success: false, success: false,
message: 'File not found or access denied', message: 'File not found or access denied',
}); });
@ -159,11 +182,12 @@ imagesRouter.get(
// Validate query parameters // Validate query parameters
if (isNaN(limit) || isNaN(offset)) { if (isNaN(limit) || isNaN(offset)) {
return res.status(400).json({ res.status(400).json({
success: false, success: false,
message: 'Invalid query parameters', message: 'Invalid query parameters',
error: 'limit and offset must be valid numbers', error: 'limit and offset must be valid numbers',
}); });
return;
} }
// Extract org/project from validated API key // Extract org/project from validated API key

View File

@ -9,7 +9,6 @@ import { validateAndNormalizePagination } from '@/utils/validators';
import { buildPaginatedResponse } from '@/utils/helpers'; import { buildPaginatedResponse } from '@/utils/helpers';
import { toFlowResponse, toGenerationResponse, toImageResponse } from '@/types/responses'; import { toFlowResponse, toGenerationResponse, toImageResponse } from '@/types/responses';
import type { import type {
CreateFlowResponse,
ListFlowsResponse, ListFlowsResponse,
GetFlowResponse, GetFlowResponse,
UpdateFlowAliasesResponse, UpdateFlowAliasesResponse,

View File

@ -172,24 +172,26 @@ imagesRouter.post(
pendingFlowId = null; pendingFlowId = null;
} else { } else {
// Specific flowId provided - ensure flow exists (eager creation) // Specific flowId provided - ensure flow exists (eager creation)
finalFlowId = flowId; // Use flowId directly since TypeScript has narrowed it to string in this branch
const providedFlowId = flowId;
finalFlowId = providedFlowId;
pendingFlowId = null; pendingFlowId = null;
// Check if flow exists, create if not // Check if flow exists, create if not
const existingFlow = await db.query.flows.findFirst({ const existingFlow = await db.query.flows.findFirst({
where: eq(flows.id, finalFlowId), where: eq(flows.id, providedFlowId),
}); });
if (!existingFlow) { if (!existingFlow) {
await db.insert(flows).values({ await db.insert(flows).values({
id: finalFlowId, id: providedFlowId,
projectId, projectId,
aliases: {}, aliases: {},
meta: {}, meta: {},
}); });
// Link any pending images to this new flow // Link any pending images to this new flow
await service.linkPendingImagesToFlow(finalFlowId, projectId); await service.linkPendingImagesToFlow(providedFlowId, projectId);
} }
} }

View File

@ -1,6 +1,6 @@
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 } 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

View File

@ -203,7 +203,7 @@ export class MinioStorageService implements StorageService {
console.log(`Uploading file to: ${this.bucketName}/${filePath}`); console.log(`Uploading file to: ${this.bucketName}/${filePath}`);
const result = await this.client.putObject( await this.client.putObject(
this.bucketName, this.bucketName,
filePath, filePath,
buffer, buffer,
@ -315,10 +315,11 @@ 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}` : ''); // Access protected properties via type assertion for URL replacement
const publicEndpoint = this.publicUrl.replace(/^https?:\/\//, ''); const client = this.client as unknown as { host: string; port: number; protocol: string };
const clientEndpoint = client.host + (client.port ? `:${client.port}` : '');
return presignedUrl.replace(`${this.client.protocol}//${clientEndpoint}`, this.publicUrl); return presignedUrl.replace(`${client.protocol}//${clientEndpoint}`, this.publicUrl);
} }
return presignedUrl; return presignedUrl;
@ -351,12 +352,11 @@ export class MinioStorageService implements StorageService {
} }
files.push({ files.push({
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), lastModified: obj.lastModified || new Date(),
createdAt: obj.lastModified || new Date(), path: obj.name,
}); });
} catch (error) {} } catch (error) {}
}); });

View File

@ -151,4 +151,18 @@ export interface StorageService {
category: 'uploads' | 'generated' | 'references', category: 'uploads' | 'generated' | 'references',
filename: string, filename: string,
): Promise<boolean>; ): Promise<boolean>;
/**
* Get the public URL for a file
* @param orgId Organization ID
* @param projectId Project ID
* @param category File category
* @param filename Filename
*/
getPublicUrl(
orgId: string,
projectId: string,
category: 'uploads' | 'generated' | 'references',
filename: string,
): string;
} }

View File

@ -190,7 +190,7 @@ export class AliasService {
}); });
} }
async validateAliasForAssignment(alias: string, projectId: string, flowId?: string): Promise<void> { async validateAliasForAssignment(alias: string, _projectId: string, _flowId?: string): Promise<void> {
const formatResult = validateAliasFormat(alias); const formatResult = validateAliasFormat(alias);
if (!formatResult.valid) { if (!formatResult.valid) {
throw new Error(formatResult.error!.message); throw new Error(formatResult.error!.message);

View File

@ -532,7 +532,7 @@ export class GenerationService {
} }
// Keep retry() for backward compatibility, delegate to regenerate() // Keep retry() for backward compatibility, delegate to regenerate()
async retry(id: string, overrides?: { prompt?: string; aspectRatio?: string }): Promise<GenerationWithRelations> { async retry(id: string, _overrides?: { prompt?: string; aspectRatio?: string }): Promise<GenerationWithRelations> {
// Ignore overrides, regenerate with original parameters // Ignore overrides, regenerate with original parameters
return await this.regenerate(id); return await this.regenerate(id);
} }