From db82340632a27a3d8e3334089f5888b164e61298 Mon Sep 17 00:00:00 2001 From: Rezkaudi2002 Date: Sun, 29 Mar 2026 18:53:08 +0300 Subject: [PATCH 01/11] add error handling for component and project deletion; return appropriate status codes --- .../ui-library/delete-ui-library-component.use-case.ts | 3 +++ .../ui-library/delete-ui-library-project.use-case.ts | 4 ++++ .../web/controllers/ui-library.controller.ts | 8 ++++++-- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/backend/src/application/use-cases/ui-library/delete-ui-library-component.use-case.ts b/backend/src/application/use-cases/ui-library/delete-ui-library-component.use-case.ts index 09c0986..78752c9 100644 --- a/backend/src/application/use-cases/ui-library/delete-ui-library-component.use-case.ts +++ b/backend/src/application/use-cases/ui-library/delete-ui-library-component.use-case.ts @@ -9,6 +9,9 @@ export class DeleteUILibraryComponentUseCase { async execute(componentId: string, userId: string): Promise { const component = await this.uiLibraryRepository.findComponentById(componentId, userId); + if (!component) { + throw new Error('Component not found'); + } await this.uiLibraryRepository.deleteComponent(componentId, userId); diff --git a/backend/src/application/use-cases/ui-library/delete-ui-library-project.use-case.ts b/backend/src/application/use-cases/ui-library/delete-ui-library-project.use-case.ts index b155371..4848a81 100644 --- a/backend/src/application/use-cases/ui-library/delete-ui-library-project.use-case.ts +++ b/backend/src/application/use-cases/ui-library/delete-ui-library-project.use-case.ts @@ -4,6 +4,10 @@ export class DeleteUILibraryProjectUseCase { constructor(private readonly uiLibraryRepository: IUILibraryRepository) {} async execute(projectId: string, userId: string): Promise { + const project = await this.uiLibraryRepository.findProjectById(projectId, userId); + if (!project) { + throw new Error('Project not found'); + } await this.uiLibraryRepository.deleteProject(projectId, userId); } } diff --git a/backend/src/infrastructure/web/controllers/ui-library.controller.ts b/backend/src/infrastructure/web/controllers/ui-library.controller.ts index 9386329..076bb73 100644 --- a/backend/src/infrastructure/web/controllers/ui-library.controller.ts +++ b/backend/src/infrastructure/web/controllers/ui-library.controller.ts @@ -62,7 +62,9 @@ export class UILibraryController { message: 'Project deleted successfully', }); } catch (error) { - next(error); + const message = error instanceof Error ? error.message : 'Failed to delete project'; + const statusCode = message.toLowerCase().includes('not found') ? 404 : 500; + res.status(statusCode).json({ success: false, message }); } } @@ -119,7 +121,9 @@ export class UILibraryController { message: 'Component deleted successfully', }); } catch (error) { - next(error); + const message = error instanceof Error ? error.message : 'Failed to delete component'; + const statusCode = message.toLowerCase().includes('not found') ? 404 : 500; + res.status(statusCode).json({ success: false, message }); } } From 1f1bf7c05478631e79522c9acd88beef27eaa2b5 Mon Sep 17 00:00:00 2001 From: Rezkaudi2002 Date: Sun, 29 Mar 2026 19:32:04 +0300 Subject: [PATCH 02/11] add image validation for S3 uploads; enforce MIME type and size checks --- .../services/storage/s3.service.ts | 48 ++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/backend/src/infrastructure/services/storage/s3.service.ts b/backend/src/infrastructure/services/storage/s3.service.ts index ea35bc6..451b640 100644 --- a/backend/src/infrastructure/services/storage/s3.service.ts +++ b/backend/src/infrastructure/services/storage/s3.service.ts @@ -2,6 +2,47 @@ import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client import { ENV_CONFIG } from '../../config/env.config'; import { randomUUID } from 'crypto'; +const ALLOWED_MIME_TYPES: Record = { + 'image/png': { + extension: 'png', + magic: [Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])], + }, + 'image/jpeg': { + extension: 'jpg', + magic: [Buffer.from([0xff, 0xd8, 0xff])], + }, + 'image/webp': { + extension: 'webp', + // RIFF????WEBP — bytes 0-3 are RIFF, bytes 8-11 are WEBP + magic: [Buffer.from([0x52, 0x49, 0x46, 0x46])], + }, +}; + +const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB + +function validateImageBuffer(buffer: Buffer, mimeType: string): void { + if (buffer.length > MAX_SIZE_BYTES) { + throw new Error(`File size ${buffer.length} exceeds the 5 MB limit`); + } + + const spec = ALLOWED_MIME_TYPES[mimeType]; + if (!spec) { + throw new Error(`MIME type "${mimeType}" is not allowed. Allowed types: ${Object.keys(ALLOWED_MIME_TYPES).join(', ')}`); + } + + const matchesMagic = spec.magic.some(magic => buffer.slice(0, magic.length).equals(magic)); + + // WebP requires an additional check on bytes 8-11 + if (mimeType === 'image/webp') { + const webpMarker = buffer.slice(8, 12).toString('ascii'); + if (!matchesMagic || webpMarker !== 'WEBP') { + throw new Error('File content does not match declared MIME type image/webp'); + } + } else if (!matchesMagic) { + throw new Error(`File content does not match declared MIME type ${mimeType}`); + } +} + export class S3Service { private readonly client: S3Client; private readonly bucket: string; @@ -19,14 +60,17 @@ export class S3Service { } async uploadBase64Image(base64DataUrl: string, folder = 'component-previews'): Promise { - const matches = base64DataUrl.match(/^data:(.+);base64,(.+)$/); + const matches = base64DataUrl.match(/^data:([a-zA-Z0-9][a-zA-Z0-9!#$&\-^_]+\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_]+);base64,([A-Za-z0-9+/]+=*)$/); if (!matches) { throw new Error('Invalid base64 image format. Expected: data:;base64,'); } const mimeType = matches[1]; const buffer = Buffer.from(matches[2], 'base64'); - const extension = mimeType.split('/')[1] || 'png'; + + validateImageBuffer(buffer, mimeType); + + const extension = ALLOWED_MIME_TYPES[mimeType].extension; const key = `${folder}/${randomUUID()}.${extension}`; await this.client.send(new PutObjectCommand({ From 7794f748f8a1abf813ec940986635c6e6da45bf5 Mon Sep 17 00:00:00 2001 From: Rezkaudi2002 Date: Sun, 29 Mar 2026 19:45:08 +0300 Subject: [PATCH 03/11] add rate limiting middleware for auth, AI generation, uploads, payments, and webhooks; update server routes to use new limits --- backend/package-lock.json | 26 +++++++++ backend/package.json | 1 + .../web/middleware/rate-limit.middleware.ts | 58 +++++++++++++++++++ backend/src/infrastructure/web/server.ts | 17 ++++-- 4 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 backend/src/infrastructure/web/middleware/rate-limit.middleware.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 98bdb25..9facba4 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -24,6 +24,7 @@ "dotenv": "^16.4.7", "express": "^5.1.0", "express-async-handler": "^1.2.0", + "express-rate-limit": "^8.3.1", "express-validator": "^7.3.1", "google-auth-library": "^10.5.0", "joi": "^18.0.2", @@ -2864,6 +2865,23 @@ "integrity": "sha512-rCSVtPXRmQSW8rmik/AIb2P0op6l7r1fMW538yyvTMltCO4xQEWMmobfrIxN2V1/mVrgxB8Az3reYF6yUZw37w==", "license": "MIT" }, + "node_modules/express-rate-limit": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express-validator": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz", @@ -3448,6 +3466,14 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/backend/package.json b/backend/package.json index 12cc353..bc1e80f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -32,6 +32,7 @@ "dotenv": "^16.4.7", "express": "^5.1.0", "express-async-handler": "^1.2.0", + "express-rate-limit": "^8.3.1", "express-validator": "^7.3.1", "google-auth-library": "^10.5.0", "joi": "^18.0.2", diff --git a/backend/src/infrastructure/web/middleware/rate-limit.middleware.ts b/backend/src/infrastructure/web/middleware/rate-limit.middleware.ts new file mode 100644 index 0000000..e11a7eb --- /dev/null +++ b/backend/src/infrastructure/web/middleware/rate-limit.middleware.ts @@ -0,0 +1,58 @@ +import rateLimit, { ipKeyGenerator } from 'express-rate-limit'; +import { Request } from 'express'; + +const userOrIpKey = (req: Request): string => + (req as any).user?.id?.toString() ?? ipKeyGenerator(req.ip ?? ''); + +const rateLimitResponse = (message: string) => ({ + success: false, + message, +}); + +// Auth endpoints: 10 req/min per IP (brute-force protection) +export const authLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: rateLimitResponse('Too many auth requests. Please try again in a minute.'), +}); + +// AI generation: 10 req/min per user (cost protection) +export const aiLimiterPerMinute = rateLimit({ + windowMs: 60 * 1000, + max: 10, + keyGenerator: userOrIpKey, + standardHeaders: true, + legacyHeaders: false, + message: rateLimitResponse('AI generation limit reached. Max 10 requests per minute.'), +}); + +// File upload: 10 req/min per user +export const uploadLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 10, + keyGenerator: userOrIpKey, + standardHeaders: true, + legacyHeaders: false, + message: rateLimitResponse('Upload limit reached. Max 10 uploads per minute.'), +}); + +// Payment/subscription checkout: 5 req/min per user +export const paymentLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 5, + keyGenerator: userOrIpKey, + standardHeaders: true, + legacyHeaders: false, + message: rateLimitResponse('Payment request limit reached. Max 5 requests per minute.'), +}); + +// Stripe webhook: 100 req/min (generous, Stripe retries legitimately) +export const webhookLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 100, + standardHeaders: true, + legacyHeaders: false, + message: rateLimitResponse('Webhook rate limit exceeded.'), +}); diff --git a/backend/src/infrastructure/web/server.ts b/backend/src/infrastructure/web/server.ts index a4ffdcd..263c70e 100644 --- a/backend/src/infrastructure/web/server.ts +++ b/backend/src/infrastructure/web/server.ts @@ -25,6 +25,13 @@ import designGenerationRoutes from './routes/design-generation.routes'; import { setupDependencies } from './dependencies'; import { logger } from './middleware/logger.middleware'; +import { + authLimiter, + aiLimiterPerMinute, + uploadLimiter, + paymentLimiter, + webhookLimiter, +} from './middleware/rate-limit.middleware'; export class Server { private app: Application; @@ -75,14 +82,16 @@ export class Server { this.container.authMiddleware.requireAuthForApi(req, res, next); }); - this.app.use('/auth', authRoutes(this.container.authController)); - this.app.use('/api/designs', designRoutes(this.container.designController)); + this.app.use('/auth', authLimiter, authRoutes(this.container.authController)); + this.app.use('/api/designs', aiLimiterPerMinute, designRoutes(this.container.designController)); this.app.use('/api/ai-models', aiModelsRoutes(this.container.aiModelsController)); this.app.use('/api/design-systems', designSystemsRoutes(this.container.designSystemsController)); this.app.use('/api/errors', clientErrorRoutes(this.container.clientErrorController)); + this.app.use('/api/ui-library/components/upload-image', uploadLimiter); this.app.use('/api/ui-library', uiLibraryRoutes(this.container.uiLibraryController)); - this.app.use('/api/payments', paymentRoutes(this.container.paymentController)); - this.app.use('/api/subscriptions', subscriptionRoutes(this.container.subscriptionController)); + this.app.use('/api/payments/webhook', webhookLimiter); + this.app.use('/api/payments', paymentLimiter, paymentRoutes(this.container.paymentController)); + this.app.use('/api/subscriptions', paymentLimiter, subscriptionRoutes(this.container.subscriptionController)); this.app.use('/api/design-generations', designGenerationRoutes(this.container.designGenerationController)); this.app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); } From eaff85055b54c8fc6803b8d208b88ee7c248e1ee Mon Sep 17 00:00:00 2001 From: Rezkaudi2002 Date: Mon, 30 Mar 2026 10:32:24 +0300 Subject: [PATCH 04/11] disable imageData handling across design node, fill, and image optimizer services --- .../src/domain/entities/design-node.ts | 4 +- figma-plugin/src/domain/entities/fill.ts | 2 +- .../figma/exporters/node.exporter.ts | 34 +++++++-------- .../src/infrastructure/mappers/fill.mapper.ts | 10 ++--- .../plugin-image-optimizer.service.ts | 42 +++++++++---------- 5 files changed, 45 insertions(+), 47 deletions(-) diff --git a/figma-plugin/src/domain/entities/design-node.ts b/figma-plugin/src/domain/entities/design-node.ts index 3ff3647..a21c5fc 100644 --- a/figma-plugin/src/domain/entities/design-node.ts +++ b/figma-plugin/src/domain/entities/design-node.ts @@ -315,8 +315,8 @@ export interface DesignNode { // Children children?: DesignNode[]; - // Image data (for embedded images) - imageData?: string; + // Image data (for embedded images) (disabled) + // imageData?: string; // SVG URL for icon nodes (uses figma.createNodeFromSvg instead of image fill) svgUrl?: string; diff --git a/figma-plugin/src/domain/entities/fill.ts b/figma-plugin/src/domain/entities/fill.ts index b6eea85..172b122 100644 --- a/figma-plugin/src/domain/entities/fill.ts +++ b/figma-plugin/src/domain/entities/fill.ts @@ -63,7 +63,7 @@ export interface Fill { // Image fill scaleMode?: 'FILL' | 'FIT' | 'CROP' | 'TILE'; imageHash?: string; - imageData?: string; // Base64 encoded image data for export/import + // imageData?: string; // Base64 encoded image data for export/import (disabled) imageUrl?: string; // URL to fetch image from (for import) imageTransform?: [[number, number, number], [number, number, number]]; scalingFactor?: number; diff --git a/figma-plugin/src/infrastructure/figma/exporters/node.exporter.ts b/figma-plugin/src/infrastructure/figma/exporters/node.exporter.ts index 7533e2b..d809248 100644 --- a/figma-plugin/src/infrastructure/figma/exporters/node.exporter.ts +++ b/figma-plugin/src/infrastructure/figma/exporters/node.exporter.ts @@ -961,23 +961,23 @@ export class NodeExporter { if (imagePaint.imageHash) { fill.imageHash = imagePaint.imageHash; - // Try to get base64 image data - if (!this.imageCache.has(imagePaint.imageHash)) { - try { - const image = figma.getImageByHash(imagePaint.imageHash); - if (image) { - const bytes = await image.getBytesAsync(); - const base64 = this.bytesToBase64(bytes); - this.imageCache.set(imagePaint.imageHash, base64); - } - } catch (e) { - console.warn('Failed to export image:', e); - } - } - - if (this.imageCache.has(imagePaint.imageHash)) { - fill.imageData = this.imageCache.get(imagePaint.imageHash); - } + // imageData export disabled — not included in exported JSON + // if (!this.imageCache.has(imagePaint.imageHash)) { + // try { + // const image = figma.getImageByHash(imagePaint.imageHash); + // if (image) { + // const bytes = await image.getBytesAsync(); + // const base64 = this.bytesToBase64(bytes); + // this.imageCache.set(imagePaint.imageHash, base64); + // } + // } catch (e) { + // console.warn('Failed to export image:', e); + // } + // } + + // if (this.imageCache.has(imagePaint.imageHash)) { + // fill.imageData = this.imageCache.get(imagePaint.imageHash); + // } } if (imagePaint.imageTransform) { diff --git a/figma-plugin/src/infrastructure/mappers/fill.mapper.ts b/figma-plugin/src/infrastructure/mappers/fill.mapper.ts index a4f94fd..e4e6758 100644 --- a/figma-plugin/src/infrastructure/mappers/fill.mapper.ts +++ b/figma-plugin/src/infrastructure/mappers/fill.mapper.ts @@ -189,11 +189,11 @@ export class FillMapper { if (fill.imageUrl) { image = await FillMapper.fetchImageFromUrl(fill.imageUrl); } - // Priority 2: If we have base64 image data, create the image - else if (fill.imageData) { - const bytes = FillMapper.base64ToBytes(fill.imageData); - image = await figma.createImage(bytes); - } + // Priority 2: imageData disabled + // else if (fill.imageData) { + // const bytes = FillMapper.base64ToBytes(fill.imageData); + // image = await figma.createImage(bytes); + // } // Priority 3: Try to get existing image by hash else if (fill.imageHash) { image = figma.getImageByHash(fill.imageHash); diff --git a/figma-plugin/src/infrastructure/services/plugin-image-optimizer.service.ts b/figma-plugin/src/infrastructure/services/plugin-image-optimizer.service.ts index edd654d..32d8185 100644 --- a/figma-plugin/src/infrastructure/services/plugin-image-optimizer.service.ts +++ b/figma-plugin/src/infrastructure/services/plugin-image-optimizer.service.ts @@ -12,7 +12,7 @@ export interface ImageReference { path: string[]; imageHash: string; - imageData: string; + // imageData: string; // disabled scaleMode?: string; } @@ -89,18 +89,16 @@ export class ImageOptimizerService { if (node.fills && Array.isArray(node.fills)) { node.fills.forEach((fill: any, fillIndex: number) => { if (fill.type === 'IMAGE' && fill.imageData) { - const imagePath = [...currentPath, 'fills', fillIndex]; - - imageReferences.push({ - path: imagePath, - imageHash: fill.imageHash || '', - imageData: fill.imageData, - scaleMode: fill.scaleMode - }); - - // Remove imageData - delete fill.imageData; - fill._imageStripped = true; + // imageData disabled — skipping strip/restore cycle + // const imagePath = [...currentPath, 'fills', fillIndex]; + // imageReferences.push({ + // path: imagePath, + // imageHash: fill.imageHash || '', + // imageData: fill.imageData, + // scaleMode: fill.scaleMode + // }); + // delete fill.imageData; + // fill._imageStripped = true; } }); } @@ -142,14 +140,16 @@ export class ImageOptimizerService { // Verify this is still an IMAGE fill if (fill.type === 'IMAGE' && fill._imageStripped === true) { - fill.imageData = imageRef.imageData; + // imageData disabled + // fill.imageData = imageRef.imageData; if (imageRef.scaleMode) { fill.scaleMode = imageRef.scaleMode; } delete fill._imageStripped; return true; } else if (fill.type === 'IMAGE' && fill.imageHash === imageRef.imageHash) { - fill.imageData = imageRef.imageData; + // imageData disabled + // fill.imageData = imageRef.imageData; if (imageRef.scaleMode) { fill.scaleMode = imageRef.scaleMode; } @@ -167,7 +167,8 @@ export class ImageOptimizerService { const found = this.findImageFillByHash(design, imageRef.imageHash); if (found && found.fill) { - found.fill.imageData = imageRef.imageData; + // imageData disabled + // found.fill.imageData = imageRef.imageData; if (imageRef.scaleMode) { found.fill.scaleMode = imageRef.scaleMode; } @@ -219,11 +220,8 @@ export class ImageOptimizerService { return null; } - private estimateTokenSavings(imageReferences: ImageReference[]): number { - let totalChars = 0; - for (const ref of imageReferences) { - totalChars += ref.imageData.length; - } - return Math.floor(totalChars / 4); + private estimateTokenSavings(_imageReferences: ImageReference[]): number { + // imageData disabled — always 0 savings + return 0; } } \ No newline at end of file From 1c5dfa1fc0790d312ea8682eb05427d205b96d6d Mon Sep 17 00:00:00 2001 From: Rezkaudi2002 Date: Mon, 30 Mar 2026 15:32:44 +0300 Subject: [PATCH 05/11] update rate limits for auth, AI generation, and uploads; enforce 5MB size limit for uploaded images --- .../web/middleware/rate-limit.middleware.ts | 16 ++++++++-------- .../src/infrastructure/web/validation/index.ts | 1 + 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/backend/src/infrastructure/web/middleware/rate-limit.middleware.ts b/backend/src/infrastructure/web/middleware/rate-limit.middleware.ts index e11a7eb..4783eff 100644 --- a/backend/src/infrastructure/web/middleware/rate-limit.middleware.ts +++ b/backend/src/infrastructure/web/middleware/rate-limit.middleware.ts @@ -9,33 +9,33 @@ const rateLimitResponse = (message: string) => ({ message, }); -// Auth endpoints: 10 req/min per IP (brute-force protection) +// Auth endpoints: 50 req/min per IP (brute-force protection) export const authLimiter = rateLimit({ windowMs: 60 * 1000, - max: 10, + max: 50, standardHeaders: true, legacyHeaders: false, message: rateLimitResponse('Too many auth requests. Please try again in a minute.'), }); -// AI generation: 10 req/min per user (cost protection) +// AI generation: 5 req/min per user (cost protection) export const aiLimiterPerMinute = rateLimit({ windowMs: 60 * 1000, - max: 10, + max: 5, keyGenerator: userOrIpKey, standardHeaders: true, legacyHeaders: false, - message: rateLimitResponse('AI generation limit reached. Max 10 requests per minute.'), + message: rateLimitResponse('AI generation limit reached. Max 5 requests per minute.'), }); -// File upload: 10 req/min per user +// File upload: 5 req/min per user export const uploadLimiter = rateLimit({ windowMs: 60 * 1000, - max: 10, + max: 5, keyGenerator: userOrIpKey, standardHeaders: true, legacyHeaders: false, - message: rateLimitResponse('Upload limit reached. Max 10 uploads per minute.'), + message: rateLimitResponse('Upload limit reached. Max 5 uploads per minute.'), }); // Payment/subscription checkout: 5 req/min per user diff --git a/backend/src/infrastructure/web/validation/index.ts b/backend/src/infrastructure/web/validation/index.ts index b1aba77..d1281ca 100644 --- a/backend/src/infrastructure/web/validation/index.ts +++ b/backend/src/infrastructure/web/validation/index.ts @@ -140,6 +140,7 @@ export const uploadComponentImageValidation = [ body('image') .notEmpty().withMessage('Image is required') .isString().withMessage('Image must be a string') + .isLength({ max: 5 * 1024 * 1024 }).withMessage('Image must be less than 5MB') .matches(/^data:image\/(png|jpeg|jpg|webp);base64,/).withMessage('Image must be a valid base64 data URL'), ]; From 5a43765349913cedc2be9ca22cefc72502929c7c Mon Sep 17 00:00:00 2001 From: Rezkaudi2002 Date: Mon, 30 Mar 2026 16:30:38 +0300 Subject: [PATCH 06/11] add MAX_PAYLOAD_BYTES constant; implement size checks for API requests and responses --- .../handlers/plugin-message.handler.ts | 44 ++++++++++++------- .../ui/components/modals/SaveModal.tsx | 11 ++++- .../src/presentation/ui/hooks/useApiClient.ts | 7 ++- .../src/shared/constants/plugin-config.ts | 2 + 4 files changed, 46 insertions(+), 18 deletions(-) diff --git a/figma-plugin/src/presentation/handlers/plugin-message.handler.ts b/figma-plugin/src/presentation/handlers/plugin-message.handler.ts index e60c494..45c8338 100644 --- a/figma-plugin/src/presentation/handlers/plugin-message.handler.ts +++ b/figma-plugin/src/presentation/handlers/plugin-message.handler.ts @@ -7,7 +7,7 @@ import { ExportSelectedUseCase, ExportAllUseCase, } from '../../application/use-cases'; -import { ApiConfig, defaultModel } from '../../shared/constants/plugin-config.js'; +import { ApiConfig, defaultModel, MAX_PAYLOAD_BYTES } from '../../shared/constants/plugin-config.js'; import { NodeExporter } from '../../infrastructure/figma/exporters/node.exporter'; import { GetUserInfoUseCase } from '@application/use-cases/getUserInfoUseCase'; import { errorReporter } from '../../infrastructure/services/error-reporter.service'; @@ -596,16 +596,22 @@ export class PluginMessageHandler { const requestKey = `edit_request_${Date.now()}`; this.imageReferencesStore.set(requestKey, imageReferences); + const editBody = JSON.stringify({ + message: userMessage, + history: this.conversationHistory, + currentDesign: cleanedDesign, + modelId: selectedModel, + designSystemId: designSystemId + }); + + if (editBody.length > MAX_PAYLOAD_BYTES) { + throw new Error('Selected layer is too large to edit (exceeds 5MB). Please select a smaller layer.'); + } + const response = await fetch(`${ApiConfig.BASE_URL}/api/designs/edit-with-ai`, { method: 'POST', headers: await this.getUserInfoUseCase.execute(), - body: JSON.stringify({ - message: userMessage, - history: this.conversationHistory, - currentDesign: cleanedDesign, - modelId: selectedModel, - designSystemId: designSystemId - }) + body: editBody, }); if (!response.ok) { @@ -701,17 +707,23 @@ export class PluginMessageHandler { console.log(`Plugin: sending ${cleanedReferences.length} references to backend`); + const basedOnExistingBody = JSON.stringify({ + message: userMessage, + history: conversationHistory, + referenceDesigns: cleanedReferences, + modelId: selectedModel, + pinnedComponentNames: pinnedComponentNames ?? [], + ...(imageDataUrl ? { imageDataUrl } : {}), + }); + + if (basedOnExistingBody.length > MAX_PAYLOAD_BYTES) { + throw new Error('References are too large to send (exceeds 5MB). Please attach fewer layers or components.'); + } + const response = await fetch(`${ApiConfig.BASE_URL}/api/designs/generate-based-on-existing`, { method: 'POST', headers: await this.getUserInfoUseCase.execute(), - body: JSON.stringify({ - message: userMessage, - history: conversationHistory, - referenceDesigns: cleanedReferences, - modelId: selectedModel, - pinnedComponentNames: pinnedComponentNames ?? [], - ...(imageDataUrl ? { imageDataUrl } : {}), - }) + body: basedOnExistingBody, }); if (!response.ok) { diff --git a/figma-plugin/src/presentation/ui/components/modals/SaveModal.tsx b/figma-plugin/src/presentation/ui/components/modals/SaveModal.tsx index e52c66c..91b1562 100644 --- a/figma-plugin/src/presentation/ui/components/modals/SaveModal.tsx +++ b/figma-plugin/src/presentation/ui/components/modals/SaveModal.tsx @@ -17,6 +17,7 @@ export default function SaveModal(): React.JSX.Element | null { const [projects, setProjects] = useState([]); const [isLoadingProjects, setIsLoadingProjects] = useState(false); const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(null); const componentName = useMemo(() => getComponentNameFromExportData(currentExportData), [currentExportData]); const componentNames = useMemo(() => getComponentNamesFromExportData(currentExportData), [currentExportData]); @@ -65,6 +66,7 @@ export default function SaveModal(): React.JSX.Element | null { try { setIsSaving(true); + setSaveError(null); let previewImageUrl: string | null = null; try { @@ -103,7 +105,7 @@ export default function SaveModal(): React.JSX.Element | null { setDescription(''); setSelectedProjectId(''); } catch (error) { - notify(`❌ ${(error as Error).message}`, 'error'); + setSaveError((error as Error).message); reportErrorAsync(error, { actionType: 'saveComponent', }); @@ -116,6 +118,7 @@ export default function SaveModal(): React.JSX.Element | null { dispatch({ type: 'CLOSE_SAVE_MODAL' }); setDescription(''); setSelectedProjectId(''); + setSaveError(null); }; return ( @@ -210,6 +213,12 @@ export default function SaveModal(): React.JSX.Element | null { disabled={isSaving} /> + {saveError && ( +
+ {saveError} +
+ )} +