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 path from 'path';
import { existsSync } from 'fs';
@ -20,7 +20,7 @@ const DATABASE_URL =
process.env['DATABASE_URL'] ||
'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(
`[${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 {
if (!req.apiKey) {
return res.status(401).json({
res.status(401).json({
error: 'Authentication required',
message: 'This endpoint requires authentication',
});
return;
}
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}`,
);
return res.status(403).json({
res.status(403).json({
error: 'Master key required',
message: 'This endpoint requires a master API key',
});
return;
}
next();

View File

@ -7,27 +7,30 @@ import { Request, Response, NextFunction } from 'express';
export function requireProjectKey(req: Request, res: Response, next: NextFunction): void {
// This middleware assumes validateApiKey has already run and attached req.apiKey
if (!req.apiKey) {
return res.status(401).json({
res.status(401).json({
error: 'Authentication required',
message: 'API key validation must be performed first',
});
return;
}
// Block master keys from generation endpoints
if (req.apiKey.keyType === 'master') {
return res.status(403).json({
res.status(403).json({
error: 'Forbidden',
message:
'Master keys cannot be used for image generation. Please use a project-specific API key.',
});
return;
}
// Ensure project key has required IDs
if (!req.apiKey.projectId) {
return res.status(400).json({
res.status(400).json({
error: 'Invalid API key',
message: 'Project key must be associated with a project',
});
return;
}
console.log(

View File

@ -23,20 +23,22 @@ export async function validateApiKey(
const providedKey = req.headers['x-api-key'] as string;
if (!providedKey) {
return res.status(401).json({
res.status(401).json({
error: 'Missing API key',
message: 'Provide your API key via X-API-Key header',
});
return;
}
try {
const apiKey = await apiKeyService.validateKey(providedKey);
if (!apiKey) {
return res.status(401).json({
res.status(401).json({
error: 'Invalid API key',
message: 'The provided API key is invalid, expired, or revoked',
});
return;
}
// 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 { validateApiKey } from '../../middleware/auth/validateApiKey';
import { requireMasterKey } from '../../middleware/auth/requireMasterKey';
const router = express.Router();
const router: Router = express.Router();
const apiKeyService = new ApiKeyService();
// All admin routes require master key
@ -14,12 +14,12 @@ router.use(requireMasterKey);
* Create a new API key
* POST /api/admin/keys
*/
router.post('/', async (req, res) => {
router.post('/', async (req, res): Promise<void> => {
try {
const {
type,
projectId,
organizationId,
projectId: _projectId,
organizationId: _organizationId,
organizationSlug,
projectSlug,
organizationName,
@ -30,24 +30,27 @@ router.post('/', async (req, res) => {
// Validation
if (!type || !['master', 'project'].includes(type)) {
return res.status(400).json({
res.status(400).json({
error: 'Invalid type',
message: 'Type must be either "master" or "project"',
});
return;
}
if (type === 'project' && !projectSlug) {
return res.status(400).json({
res.status(400).json({
error: 'Missing projectSlug',
message: 'Project keys require a projectSlug',
});
return;
}
if (type === 'project' && !organizationSlug) {
return res.status(400).json({
res.status(400).json({
error: 'Missing organizationSlug',
message: 'Project keys require an organizationSlug',
});
return;
}
// Create key
@ -148,17 +151,18 @@ router.get('/', async (req, res) => {
* Revoke an API key
* DELETE /api/admin/keys/:keyId
*/
router.delete('/:keyId', async (req, res) => {
router.delete('/:keyId', async (req, res): Promise<void> => {
try {
const { keyId } = req.params;
const success = await apiKeyService.revokeKey(keyId);
if (!success) {
return res.status(404).json({
res.status(404).json({
error: 'Key not found',
message: 'The specified API key does not exist',
});
return;
}
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';
const router = express.Router();
const router: Router = express.Router();
const apiKeyService = new ApiKeyService();
/**
@ -10,17 +10,18 @@ const apiKeyService = new ApiKeyService();
*
* POST /api/bootstrap/initial-key
*/
router.post('/initial-key', async (req, res) => {
router.post('/initial-key', async (_req, res): Promise<void> => {
try {
// Check if any keys already exist
const hasKeys = await apiKeyService.hasAnyKeys();
if (hasKeys) {
console.warn(`[${new Date().toISOString()}] Bootstrap attempt when keys already exist`);
return res.status(403).json({
res.status(403).json({
error: 'Bootstrap not allowed',
message: 'API keys already exist. Use /api/admin/keys to create new keys.',
});
return;
}
// Create first master key

View File

@ -1,11 +1,12 @@
import { Router, Request, Response } from 'express';
import type { Router as RouterType } from 'express';
import { StorageFactory } from '../services/StorageFactory';
import { asyncHandler } from '../middleware/errorHandler';
import { validateApiKey } from '../middleware/auth/validateApiKey';
import { requireProjectKey } from '../middleware/auth/requireProjectKey';
import { rateLimitByApiKey } from '../middleware/auth/rateLimiter';
export const imagesRouter = Router();
export const imagesRouter: RouterType = Router();
/**
* GET /api/images/:orgId/:projectId/:category/:filename
@ -13,15 +14,25 @@ export const imagesRouter = Router();
*/
imagesRouter.get(
'/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;
// 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
if (!['uploads', 'generated', 'references'].includes(category)) {
return res.status(400).json({
res.status(400).json({
success: false,
message: 'Invalid category',
});
return;
}
const storageService = await StorageFactory.getInstance();
@ -36,10 +47,11 @@ imagesRouter.get(
);
if (!exists) {
return res.status(404).json({
res.status(404).json({
success: false,
message: 'File not found',
});
return;
}
// Determine content type from filename
@ -62,7 +74,8 @@ imagesRouter.get(
// Handle conditional requests (304 Not Modified)
const ifNoneMatch = req.headers['if-none-match'];
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)
@ -88,7 +101,7 @@ imagesRouter.get(
fileStream.pipe(res);
} catch (error) {
console.error('Failed to stream file:', error);
return res.status(404).json({
res.status(404).json({
success: false,
message: 'File not found',
});
@ -102,15 +115,25 @@ imagesRouter.get(
*/
imagesRouter.get(
'/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 { 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)) {
return res.status(400).json({
res.status(400).json({
success: false,
message: 'Invalid category',
});
return;
}
const storageService = await StorageFactory.getInstance();
@ -124,14 +147,14 @@ imagesRouter.get(
parseInt(expiry as string, 10),
);
return res.json({
res.json({
success: true,
url: presignedUrl,
expiresIn: parseInt(expiry as string, 10),
});
} catch (error) {
console.error('Failed to generate presigned URL:', error);
return res.status(404).json({
res.status(404).json({
success: false,
message: 'File not found or access denied',
});
@ -159,11 +182,12 @@ imagesRouter.get(
// Validate query parameters
if (isNaN(limit) || isNaN(offset)) {
return res.status(400).json({
res.status(400).json({
success: false,
message: 'Invalid query parameters',
error: 'limit and offset must be valid numbers',
});
return;
}
// Extract org/project from validated API key

View File

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

View File

@ -172,24 +172,26 @@ imagesRouter.post(
pendingFlowId = null;
} else {
// 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;
// Check if flow exists, create if not
const existingFlow = await db.query.flows.findFirst({
where: eq(flows.id, finalFlowId),
where: eq(flows.id, providedFlowId),
});
if (!existingFlow) {
await db.insert(flows).values({
id: finalFlowId,
id: providedFlowId,
projectId,
aliases: {},
meta: {},
});
// 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 { 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';
// 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}`);
const result = await this.client.putObject(
await this.client.putObject(
this.bucketName,
filePath,
buffer,
@ -315,10 +315,11 @@ export class MinioStorageService implements StorageService {
// Replace internal Docker hostname with public URL if configured
if (this.publicUrl) {
const clientEndpoint = this.client.host + (this.client.port ? `:${this.client.port}` : '');
const publicEndpoint = this.publicUrl.replace(/^https?:\/\//, '');
// Access protected properties via type assertion for URL replacement
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;
@ -351,12 +352,11 @@ export class MinioStorageService implements StorageService {
}
files.push({
key: `${this.bucketName}/${obj.name}`,
filename,
contentType: metadata.metaData?.['content-type'] || 'application/octet-stream',
size: obj.size || 0,
url: this.getPublicUrl(orgId, projectId, categoryFromPath, filename),
createdAt: obj.lastModified || new Date(),
lastModified: obj.lastModified || new Date(),
path: obj.name,
});
} catch (error) {}
});

View File

@ -151,4 +151,18 @@ export interface StorageService {
category: 'uploads' | 'generated' | 'references',
filename: string,
): 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);
if (!formatResult.valid) {
throw new Error(formatResult.error!.message);

View File

@ -532,7 +532,7 @@ export class GenerationService {
}
// 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
return await this.regenerate(id);
}