From 3209247b5490b51d7a3a1916c6b26045055fdd00 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Nov 2025 13:52:54 +0000 Subject: [PATCH] feat: Add comprehensive API enhancements and accessibility improvements This commit addresses 8 medium priority items to improve the overall quality, security, and accessibility of the application: **API Enhancements:** 1. Consistent error logging - Implemented structured logging across all handlers using the Logger class instead of console.error, providing better observability and debugging capabilities 2. Request rate limiting - Added token bucket algorithm-based rate limiting middleware to prevent API abuse and protect against DoS attacks 3. Multipart file upload support - Implemented proper multipart/form-data parser for file uploads, enabling standard file upload workflows while maintaining backward compatibility with legacy formats 4. Health check endpoints - Added /health and /ready endpoints for monitoring and load balancer health checks, including database connectivity verification 5. Monitoring and metrics collection - Implemented metrics middleware collecting request counts, response times, error rates, and status code distribution with a /metrics endpoint 6. API request validation middleware - Created comprehensive validation middleware supporting schema-based validation for body, query parameters, and headers with built-in validation rules 7. Caching strategy - Implemented in-memory response caching with TTL, configurable cache keys, and automatic cache invalidation **Web App Enhancements:** 8. Missing ARIA labels - Added ARIA labels to Card component with proper role attributes, keyboard navigation support, and focus management All changes follow SOLID principles and maintain backward compatibility. Type checking passes for API code. Ready for integration testing. --- api/src/handlers/files/upload-file.ts | 85 +++- api/src/handlers/health/health-check.ts | 194 ++++++++++ api/src/handlers/health/metrics.ts | 76 ++++ api/src/handlers/health/readiness-check.ts | 33 ++ api/src/handlers/index.ts | 5 + api/src/middleware/cache.ts | 353 +++++++++++++++++ api/src/middleware/error-handler.ts | 24 +- api/src/middleware/index.ts | 4 + api/src/middleware/metrics.ts | 297 ++++++++++++++ api/src/middleware/rate-limit.ts | 279 ++++++++++++++ api/src/middleware/validation.ts | 428 +++++++++++++++++++++ api/src/utils/multipart-parser.ts | 258 +++++++++++++ web-app/src/components/common/Card.tsx | 26 ++ 13 files changed, 2051 insertions(+), 11 deletions(-) create mode 100644 api/src/handlers/health/health-check.ts create mode 100644 api/src/handlers/health/metrics.ts create mode 100644 api/src/handlers/health/readiness-check.ts create mode 100644 api/src/middleware/cache.ts create mode 100644 api/src/middleware/metrics.ts create mode 100644 api/src/middleware/rate-limit.ts create mode 100644 api/src/middleware/validation.ts create mode 100644 api/src/utils/multipart-parser.ts diff --git a/api/src/handlers/files/upload-file.ts b/api/src/handlers/files/upload-file.ts index 786f757..8d73605 100644 --- a/api/src/handlers/files/upload-file.ts +++ b/api/src/handlers/files/upload-file.ts @@ -11,6 +11,11 @@ import { APIGatewayResponse } from '../../types/api'; import { AuthenticatedEvent, withAuth } from '../../middleware/auth-middleware'; import { FileRepository } from '../../repositories/file-repository.impl'; import { createDatabaseConnection } from '../../utils/database-connection'; +import { createLogger } from '../../middleware/logging'; +import { + parseMultipartFormData as parseMultipart, + validateMultipartRequest, +} from '../../utils/multipart-parser'; import { ValidationError, FileProcessingError, @@ -75,10 +80,57 @@ interface FileUploadData { /** * Parse multipart form data from API Gateway event - * Simplified parser for base64-encoded binary data + * Supports both multipart/form-data and legacy JSON/base64 formats */ function parseMultipartFormData(event: AuthenticatedEvent): FileUploadData { - // For base64-encoded body (API Gateway binary support) + const contentType = event.headers['content-type'] || event.headers['Content-Type'] || ''; + + // Handle multipart/form-data + if (contentType.includes('multipart/form-data')) { + const validation = validateMultipartRequest(contentType); + if (!validation.isValid || !validation.boundary) { + throw new ValidationError(validation.error || 'Invalid multipart request'); + } + + if (!event.body) { + throw new ValidationError('No request body provided'); + } + + // Parse multipart data + const body = event.isBase64Encoded + ? Buffer.from(event.body, 'base64') + : Buffer.from(event.body); + const parsed = parseMultipart(body, validation.boundary); + + // Get the first file + if (parsed.files.length === 0) { + throw new ValidationError('No file found in multipart request'); + } + + const file = parsed.files[0]; + + // Extract metadata from fields + const metadata: Record = {}; + for (const [key, value] of Object.entries(parsed.fields)) { + if (key !== 'file' && key !== 'fileName' && key !== 'mimeType') { + try { + metadata[key] = JSON.parse(value); + } catch { + metadata[key] = value; + } + } + } + + return { + fileName: file.filename, + fileSize: file.size, + mimeType: file.contentType, + fileContent: file.data, + metadata: Object.keys(metadata).length > 0 ? metadata : undefined, + }; + } + + // Legacy: For base64-encoded body (API Gateway binary support) if (event.isBase64Encoded && event.body) { const buffer = Buffer.from(event.body, 'base64'); @@ -110,7 +162,7 @@ function parseMultipartFormData(event: AuthenticatedEvent): FileUploadData { }; } - // For JSON body with base64 file content + // Legacy: For JSON body with base64 file content if (event.body) { try { const body = JSON.parse(event.body); @@ -213,7 +265,11 @@ async function uploadToS3( s3Bucket: S3_BUCKET_NAME, }; } catch (error) { - console.error('S3 upload error:', error); + const logger = createLogger({ userId, s3Key }); + logger.error('S3 upload failed', { + error: error instanceof Error ? error.message : 'Unknown error', + errorStack: error instanceof Error ? error.stack : undefined, + }); throw new ExternalServiceError('S3', 'Failed to upload file to storage', { error: error instanceof Error ? error.message : 'Unknown error', }); @@ -246,7 +302,12 @@ async function storeFileMetadata( return fileRecord.id; } catch (error) { - console.error('Database error:', error); + const logger = createLogger({ userId, s3Key }); + logger.error('Database operation failed', { + operation: 'storeFileMetadata', + error: error instanceof Error ? error.message : 'Unknown error', + errorStack: error instanceof Error ? error.stack : undefined, + }); throw new DatabaseError('Failed to store file metadata', { error: error instanceof Error ? error.message : 'Unknown error', }); @@ -284,8 +345,14 @@ function createSuccessResponse( /** * Create error response */ -function createErrorResponse(error: Error): APIGatewayResponse { - console.error('Handler error:', error); +function createErrorResponse(error: Error, requestId?: string): APIGatewayResponse { + const logger = createLogger({ requestId }); + + logger.error('File upload handler error', { + errorType: error.name, + message: error.message, + stack: error.stack, + }); // Handle known error types if ( @@ -321,6 +388,8 @@ function createErrorResponse(error: Error): APIGatewayResponse { * Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 3.6 */ async function handleFileUpload(event: AuthenticatedEvent): Promise { + const requestId = event.requestContext?.requestId; + try { // Ensure user is authenticated (middleware handles this) if (!event.user?.userId) { @@ -344,7 +413,7 @@ async function handleFileUpload(event: AuthenticatedEvent): Promise { + const startTime = Date.now(); + + try { + const db = await createDatabaseConnection(); + + // Simple query to check connectivity + await db.query('SELECT 1'); + await db.disconnect(); + + const responseTime = Date.now() - startTime; + + return { + status: 'healthy', + message: 'Database connection successful', + responseTime, + }; + } catch (error) { + const responseTime = Date.now() - startTime; + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + return { + status: 'unhealthy', + message: `Database connection failed: ${errorMessage}`, + responseTime, + }; + } +} + +/** + * Check memory health + */ +function checkMemoryHealth(): ComponentHealth { + const usage = process.memoryUsage(); + const heapUsedMB = Math.round(usage.heapUsed / 1024 / 1024); + const heapTotalMB = Math.round(usage.heapTotal / 1024 / 1024); + const percentUsed = Math.round((usage.heapUsed / usage.heapTotal) * 100); + + // Consider unhealthy if using more than 90% of heap + if (percentUsed > 90) { + return { + status: 'unhealthy', + message: `High memory usage: ${heapUsedMB}MB / ${heapTotalMB}MB (${percentUsed}%)`, + }; + } + + // Consider degraded if using more than 75% of heap + if (percentUsed > 75) { + return { + status: 'degraded', + message: `Elevated memory usage: ${heapUsedMB}MB / ${heapTotalMB}MB (${percentUsed}%)`, + }; + } + + return { + status: 'healthy', + message: `Memory usage normal: ${heapUsedMB}MB / ${heapTotalMB}MB (${percentUsed}%)`, + }; +} + +/** + * Determine overall health status + */ +function determineOverallStatus(components: { + database: ComponentHealth; + memory: ComponentHealth; +}): HealthStatus { + // If any component is unhealthy, overall is unhealthy + if (components.database.status === 'unhealthy' || components.memory.status === 'unhealthy') { + return 'unhealthy'; + } + + // If any component is degraded, overall is degraded + if (components.database.status === 'degraded' || components.memory.status === 'degraded') { + return 'degraded'; + } + + // All components healthy + return 'healthy'; +} + +/** + * Main health check handler + */ +async function handleHealthCheck(event: APIGatewayEvent): Promise { + const logger = createLogger({ + handler: 'healthCheck', + requestId: event.requestContext?.requestId, + }); + + logger.info('Health check requested'); + + try { + // Check all components + const [databaseHealth, memoryHealth] = await Promise.all([ + checkDatabaseHealth(), + checkMemoryHealth(), + ]); + + const components = { + database: databaseHealth, + memory: memoryHealth, + }; + + const overallStatus = determineOverallStatus(components); + + const response: HealthCheckResponse = { + status: overallStatus, + timestamp: new Date().toISOString(), + uptime: process.uptime(), + version: process.env.APP_VERSION || '1.0.0', + components, + }; + + // Return appropriate HTTP status code + const statusCode = overallStatus === 'healthy' ? 200 : overallStatus === 'degraded' ? 200 : 503; + + logger.info('Health check completed', { status: overallStatus, statusCode }); + + return { + statusCode, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + }, + body: JSON.stringify(response), + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Health check failed', { error: errorMessage }); + + return { + statusCode: 503, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + status: 'unhealthy', + timestamp: new Date().toISOString(), + message: 'Health check failed', + error: errorMessage, + }), + }; + } +} + +/** + * Lambda handler export + */ +export const handler = handleHealthCheck; diff --git a/api/src/handlers/health/metrics.ts b/api/src/handlers/health/metrics.ts new file mode 100644 index 0000000..e198796 --- /dev/null +++ b/api/src/handlers/health/metrics.ts @@ -0,0 +1,76 @@ +/** + * Metrics Endpoint Lambda Handler + * Exposes collected metrics for monitoring + * Requirements: Monitoring and observability + * Following SOLID principles: Single Responsibility + */ + +import { APIGatewayEvent, APIGatewayResponse } from '../../types/api'; +import { getMetricsStore } from '../../middleware/metrics'; +import { createLogger } from '../../middleware/logging'; + +/** + * Metrics endpoint handler + * Returns aggregated metrics in JSON format + */ +async function handleMetricsRequest(event: APIGatewayEvent): Promise { + const logger = createLogger({ + handler: 'metrics', + requestId: event.requestContext?.requestId, + }); + + logger.info('Metrics requested'); + + try { + const metricsStore = getMetricsStore(); + const summary = metricsStore.getMetricsSummary(); + + // Add system metrics + const systemMetrics = { + uptime: process.uptime(), + memory: { + heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), // MB + heapTotal: Math.round(process.memoryUsage().heapTotal / 1024 / 1024), // MB + external: Math.round(process.memoryUsage().external / 1024 / 1024), // MB + rss: Math.round(process.memoryUsage().rss / 1024 / 1024), // MB + }, + cpu: process.cpuUsage(), + }; + + const response = { + timestamp: new Date().toISOString(), + system: systemMetrics, + metrics: summary, + }; + + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + }, + body: JSON.stringify(response, null, 2), + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Failed to retrieve metrics', { error: errorMessage }); + + return { + statusCode: 500, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + error: 'Failed to retrieve metrics', + message: errorMessage, + }), + }; + } +} + +/** + * Lambda handler export + */ +export const handler = handleMetricsRequest; diff --git a/api/src/handlers/health/readiness-check.ts b/api/src/handlers/health/readiness-check.ts new file mode 100644 index 0000000..0ef80fa --- /dev/null +++ b/api/src/handlers/health/readiness-check.ts @@ -0,0 +1,33 @@ +/** + * Readiness Check Lambda Handler + * Lightweight check for load balancer readiness probes + * Requirements: Monitoring and observability + * Following SOLID principles: Single Responsibility + */ + +import { APIGatewayEvent, APIGatewayResponse } from '../../types/api'; + +/** + * Simple readiness check handler + * Returns 200 if the Lambda is ready to accept requests + * No heavy checks - just confirms the Lambda is alive + */ +async function handleReadinessCheck(_event: APIGatewayEvent): Promise { + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + }, + body: JSON.stringify({ + ready: true, + timestamp: new Date().toISOString(), + }), + }; +} + +/** + * Lambda handler export + */ +export const handler = handleReadinessCheck; diff --git a/api/src/handlers/index.ts b/api/src/handlers/index.ts index 6db76e2..a68b2a9 100644 --- a/api/src/handlers/index.ts +++ b/api/src/handlers/index.ts @@ -10,3 +10,8 @@ export { handler as uploadFileHandler } from './files/upload-file'; export { handler as getUserFilesHandler } from './api/get-user-files'; export { handler as getFileMetadataHandler } from './api/get-file-metadata'; export { handler as generatePresignedUrlHandler } from './api/generate-presigned-url'; + +// Health check handlers +export { handler as healthCheckHandler } from './health/health-check'; +export { handler as readinessCheckHandler } from './health/readiness-check'; +export { handler as metricsHandler } from './health/metrics'; diff --git a/api/src/middleware/cache.ts b/api/src/middleware/cache.ts new file mode 100644 index 0000000..b5f57ce --- /dev/null +++ b/api/src/middleware/cache.ts @@ -0,0 +1,353 @@ +/** + * Response Caching Middleware for Lambda Functions + * Implements in-memory caching with TTL + * Requirements: Performance optimization + * Following SOLID principles: Single Responsibility + */ + +import { APIGatewayEvent, APIGatewayResponse } from '../types/api'; +import { createLogger } from './logging'; + +/** + * Cache entry + */ +interface CacheEntry { + response: APIGatewayResponse; + timestamp: number; + ttl: number; + hits: number; +} + +/** + * Cache configuration + */ +export interface CacheConfig { + /** + * Time to live in seconds + */ + ttl: number; + + /** + * Key generator function + */ + keyGenerator?: (event: APIGatewayEvent) => string; + + /** + * Methods to cache (default: GET only) + */ + methods?: string[]; + + /** + * Skip caching for certain requests + */ + skip?: (event: APIGatewayEvent) => boolean; + + /** + * Only cache responses with these status codes + */ + statusCodes?: number[]; + + /** + * Include query parameters in cache key + */ + includeQueryParams?: boolean; + + /** + * Include headers in cache key (e.g., for authorization) + */ + includeHeaders?: string[]; +} + +/** + * Cache store + */ +class CacheStore { + private cache: Map = new Map(); + private cleanupInterval: NodeJS.Timeout | null = null; + + constructor() { + // Clean up expired entries every minute + this.cleanupInterval = setInterval(() => { + this.cleanup(); + }, 60 * 1000); + } + + /** + * Get cached entry + */ + get(key: string): APIGatewayResponse | null { + const entry = this.cache.get(key); + + if (!entry) { + return null; + } + + // Check if expired + const now = Date.now(); + if (now - entry.timestamp > entry.ttl * 1000) { + this.cache.delete(key); + return null; + } + + // Increment hit counter + entry.hits++; + + return entry.response; + } + + /** + * Set cache entry + */ + set(key: string, response: APIGatewayResponse, ttl: number): void { + this.cache.set(key, { + response, + timestamp: Date.now(), + ttl, + hits: 0, + }); + } + + /** + * Delete cache entry + */ + delete(key: string): void { + this.cache.delete(key); + } + + /** + * Clear all cache entries + */ + clear(): void { + this.cache.clear(); + } + + /** + * Clean up expired entries + */ + private cleanup(): void { + const now = Date.now(); + const entriesToDelete: string[] = []; + + for (const [key, entry] of this.cache.entries()) { + if (now - entry.timestamp > entry.ttl * 1000) { + entriesToDelete.push(key); + } + } + + for (const key of entriesToDelete) { + this.cache.delete(key); + } + } + + /** + * Get cache statistics + */ + getStats(): { + size: number; + entries: Array<{ key: string; hits: number; age: number }>; + } { + const now = Date.now(); + const entries = Array.from(this.cache.entries()).map(([key, entry]) => ({ + key, + hits: entry.hits, + age: Math.floor((now - entry.timestamp) / 1000), + })); + + return { + size: this.cache.size, + entries, + }; + } + + /** + * Destroy the store + */ + destroy(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + this.cache.clear(); + } +} + +/** + * Global cache store + */ +const cacheStore = new CacheStore(); + +/** + * Lambda handler type + */ +export type CacheWrappedHandler = (event: APIGatewayEvent) => Promise; + +/** + * Default key generator + */ +function defaultKeyGenerator( + event: APIGatewayEvent, + includeQueryParams: boolean = true, + includeHeaders: string[] = [] +): string { + const parts: string[] = [event.httpMethod || '', event.path || '']; + + // Include query parameters + if (includeQueryParams && event.queryStringParameters) { + const sortedParams = Object.entries(event.queryStringParameters) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}=${v}`) + .join('&'); + if (sortedParams) { + parts.push(sortedParams); + } + } + + // Include specific headers + if (includeHeaders.length > 0 && event.headers) { + for (const header of includeHeaders) { + const value = event.headers[header] || event.headers[header.toLowerCase()]; + if (value) { + parts.push(`${header}:${value}`); + } + } + } + + return parts.join('|'); +} + +/** + * Cache middleware + * Caches API responses to improve performance + * + * @param config - Cache configuration + * @returns Middleware function + * + * @example + * ```typescript + * export const handler = compose( + * cacheMiddleware({ ttl: 300, methods: ['GET'] }), + * async (event) => { + * // Handler logic + * return { statusCode: 200, body: '{}' }; + * } + * ); + * ``` + */ +export function cacheMiddleware(config: CacheConfig) { + const logger = createLogger({ middleware: 'cacheMiddleware' }); + + const { + ttl, + keyGenerator, + methods = ['GET'], + skip, + statusCodes = [200], + includeQueryParams = true, + includeHeaders = [], + } = config; + + return function (handler: CacheWrappedHandler): CacheWrappedHandler { + return async (event: APIGatewayEvent): Promise => { + const method = event.httpMethod || ''; + + // Skip if method not in allowed list + if (!methods.includes(method)) { + return handler(event); + } + + // Skip if custom skip function returns true + if (skip && skip(event)) { + return handler(event); + } + + // Generate cache key + const cacheKey = keyGenerator + ? keyGenerator(event) + : defaultKeyGenerator(event, includeQueryParams, includeHeaders); + + // Try to get from cache + const cached = cacheStore.get(cacheKey); + if (cached) { + logger.debug('Cache hit', { cacheKey, path: event.path }); + + // Add cache hit header + return { + ...cached, + headers: { + ...cached.headers, + 'X-Cache': 'HIT', + }, + }; + } + + logger.debug('Cache miss', { cacheKey, path: event.path }); + + // Execute handler + const response = await handler(event); + + // Cache response if status code matches + if (statusCodes.includes(response.statusCode)) { + cacheStore.set(cacheKey, response, ttl); + logger.debug('Response cached', { cacheKey, ttl, statusCode: response.statusCode }); + } + + // Add cache miss header + return { + ...response, + headers: { + ...response.headers, + 'X-Cache': 'MISS', + 'Cache-Control': `public, max-age=${ttl}`, + }, + }; + }; + }; +} + +/** + * Get cache store (for cache management) + */ +export function getCacheStore(): CacheStore { + return cacheStore; +} + +/** + * Invalidate cache entry + */ +export function invalidateCache(key: string): void { + cacheStore.delete(key); +} + +/** + * Invalidate cache by pattern + */ +export function invalidateCacheByPattern(pattern: RegExp): number { + const stats = cacheStore.getStats(); + let count = 0; + + for (const entry of stats.entries) { + if (pattern.test(entry.key)) { + cacheStore.delete(entry.key); + count++; + } + } + + return count; +} + +/** + * Clear all cache + */ +export function clearCache(): void { + cacheStore.clear(); +} + +/** + * Get cache statistics + */ +export function getCacheStats(): { + size: number; + entries: Array<{ key: string; hits: number; age: number }>; +} { + return cacheStore.getStats(); +} diff --git a/api/src/middleware/error-handler.ts b/api/src/middleware/error-handler.ts index e3b8f30..e60e5de 100644 --- a/api/src/middleware/error-handler.ts +++ b/api/src/middleware/error-handler.ts @@ -9,6 +9,7 @@ import { APIGatewayResponse } from '../types/api'; import { AppError } from '../types/errors'; +import { createLogger } from './logging'; /** * Lambda handler type that may throw errors @@ -18,9 +19,19 @@ export type ErrorHandlerWrappedHandler = (...args: any[]) => Promise; +} + +/** + * Metric types + */ +export enum MetricType { + COUNTER = 'counter', + GAUGE = 'gauge', + HISTOGRAM = 'histogram', +} + +/** + * Metric definition + */ +interface Metric { + name: string; + type: MetricType; + description: string; + data: MetricDataPoint[]; +} + +/** + * Metrics store + */ +class MetricsStore { + private metrics: Map = new Map(); + private readonly maxDataPoints = 1000; + + /** + * Record a counter metric + */ + recordCounter(name: string, value: number = 1, labels?: Record): void { + this.ensureMetric(name, MetricType.COUNTER, `Counter metric: ${name}`); + this.addDataPoint(name, value, labels); + } + + /** + * Record a gauge metric + */ + recordGauge(name: string, value: number, labels?: Record): void { + this.ensureMetric(name, MetricType.GAUGE, `Gauge metric: ${name}`); + this.addDataPoint(name, value, labels); + } + + /** + * Record a histogram metric (for timing) + */ + recordHistogram(name: string, value: number, labels?: Record): void { + this.ensureMetric(name, MetricType.HISTOGRAM, `Histogram metric: ${name}`); + this.addDataPoint(name, value, labels); + } + + /** + * Ensure metric exists + */ + private ensureMetric(name: string, type: MetricType, description: string): void { + if (!this.metrics.has(name)) { + this.metrics.set(name, { + name, + type, + description, + data: [], + }); + } + } + + /** + * Add data point to metric + */ + private addDataPoint(name: string, value: number, labels?: Record): void { + const metric = this.metrics.get(name); + if (!metric) return; + + metric.data.push({ + timestamp: Date.now(), + value, + labels, + }); + + // Limit data points to prevent memory issues + if (metric.data.length > this.maxDataPoints) { + metric.data.shift(); + } + } + + /** + * Get all metrics + */ + getAllMetrics(): Map { + return new Map(this.metrics); + } + + /** + * Get metric by name + */ + getMetric(name: string): Metric | undefined { + return this.metrics.get(name); + } + + /** + * Get aggregated metrics summary + */ + getMetricsSummary(): Record { + const summary: Record = {}; + + for (const [name, metric] of this.metrics) { + if (metric.data.length === 0) continue; + + const values = metric.data.map((d) => d.value); + const sum = values.reduce((a, b) => a + b, 0); + const count = values.length; + + switch (metric.type) { + case MetricType.COUNTER: + summary[name] = { + type: 'counter', + total: sum, + count, + }; + break; + + case MetricType.GAUGE: + summary[name] = { + type: 'gauge', + current: values[values.length - 1], + min: Math.min(...values), + max: Math.max(...values), + avg: sum / count, + }; + break; + + case MetricType.HISTOGRAM: { + const sorted = [...values].sort((a, b) => a - b); + summary[name] = { + type: 'histogram', + count, + sum, + min: sorted[0], + max: sorted[sorted.length - 1], + avg: sum / count, + p50: sorted[Math.floor(count * 0.5)], + p95: sorted[Math.floor(count * 0.95)], + p99: sorted[Math.floor(count * 0.99)], + }; + break; + } + } + } + + return summary; + } + + /** + * Clear all metrics + */ + clear(): void { + this.metrics.clear(); + } +} + +/** + * Global metrics store + */ +const metricsStore = new MetricsStore(); + +/** + * Lambda handler type + */ +export type MetricsWrappedHandler = (event: APIGatewayEvent) => Promise; + +/** + * Metrics middleware + * Collects performance and usage metrics for each request + * + * @param handler - Lambda handler function + * @returns Wrapped handler with metrics collection + * + * @example + * ```typescript + * export const handler = compose( + * metricsMiddleware, + * async (event) => { + * // Handler logic + * return { statusCode: 200, body: '{}' }; + * } + * ); + * ``` + */ +export function metricsMiddleware(handler: MetricsWrappedHandler): MetricsWrappedHandler { + return async (event: APIGatewayEvent): Promise => { + const startTime = Date.now(); + const path = event.path || 'unknown'; + const method = event.httpMethod || 'unknown'; + + const logger = createLogger({ + middleware: 'metricsMiddleware', + requestId: event.requestContext?.requestId, + }); + + try { + // Record request count + metricsStore.recordCounter('http_requests_total', 1, { path, method }); + + // Execute handler + const response = await handler(event); + + // Record response time + const duration = Date.now() - startTime; + metricsStore.recordHistogram('http_request_duration_ms', duration, { + path, + method, + status: response.statusCode.toString(), + }); + + // Record status code + metricsStore.recordCounter('http_response_status_total', 1, { + path, + method, + status: response.statusCode.toString(), + }); + + // Record success/error + if (response.statusCode >= 200 && response.statusCode < 400) { + metricsStore.recordCounter('http_requests_success_total', 1, { path, method }); + } else { + metricsStore.recordCounter('http_requests_error_total', 1, { + path, + method, + status: response.statusCode.toString(), + }); + } + + logger.debug('Metrics recorded', { duration, statusCode: response.statusCode }); + + return response; + } catch (error) { + // Record error + const duration = Date.now() - startTime; + metricsStore.recordHistogram('http_request_duration_ms', duration, { + path, + method, + status: '500', + }); + metricsStore.recordCounter('http_requests_error_total', 1, { path, method }); + + logger.error('Request failed, metrics recorded', { duration, error }); + + throw error; + } + }; +} + +/** + * Get metrics store (for metrics endpoint) + */ +export function getMetricsStore(): MetricsStore { + return metricsStore; +} + +/** + * Record custom metric + */ +export function recordMetric( + name: string, + value: number, + type: MetricType = MetricType.COUNTER, + labels?: Record +): void { + switch (type) { + case MetricType.COUNTER: + metricsStore.recordCounter(name, value, labels); + break; + case MetricType.GAUGE: + metricsStore.recordGauge(name, value, labels); + break; + case MetricType.HISTOGRAM: + metricsStore.recordHistogram(name, value, labels); + break; + } +} diff --git a/api/src/middleware/rate-limit.ts b/api/src/middleware/rate-limit.ts new file mode 100644 index 0000000..615b7ad --- /dev/null +++ b/api/src/middleware/rate-limit.ts @@ -0,0 +1,279 @@ +/** + * Rate Limiting Middleware for Lambda Functions + * Implements token bucket algorithm to prevent API abuse + * Requirements: Security best practices + * Following SOLID principles: Single Responsibility + */ + +import { APIGatewayEvent, APIGatewayResponse } from '../types/api'; +import { createLogger } from './logging'; +import { AppError } from '../types/errors'; + +/** + * Rate limit exceeded error + */ +export class RateLimitError extends AppError { + constructor(retryAfter: number) { + super( + 'RATE_LIMIT_EXCEEDED', + `Rate limit exceeded. Retry after ${retryAfter} seconds`, + 429, + true, + { retryAfter } + ); + } +} + +/** + * Rate limit configuration + */ +export interface RateLimitConfig { + /** + * Maximum number of requests allowed in the window + */ + maxRequests: number; + + /** + * Time window in seconds + */ + windowSeconds: number; + + /** + * Key extractor function (defaults to IP address) + */ + keyExtractor?: (event: APIGatewayEvent) => string; + + /** + * Skip rate limiting for certain requests + */ + skip?: (event: APIGatewayEvent) => boolean; +} + +/** + * Rate limit bucket entry + */ +interface RateLimitBucket { + tokens: number; + lastRefill: number; + requestCount: number; +} + +/** + * In-memory rate limit store + * Note: This is suitable for Lambda but resets on cold starts + * For production, consider using DynamoDB or Redis + */ +class RateLimitStore { + private buckets: Map = new Map(); + private cleanupInterval: NodeJS.Timeout | null = null; + + constructor() { + // Clean up old entries every 5 minutes + this.cleanupInterval = setInterval( + () => { + this.cleanup(); + }, + 5 * 60 * 1000 + ); + } + + /** + * Get or create a bucket for a key + */ + getBucket(key: string, maxRequests: number): RateLimitBucket { + if (!this.buckets.has(key)) { + this.buckets.set(key, { + tokens: maxRequests, + lastRefill: Date.now(), + requestCount: 0, + }); + } + return this.buckets.get(key)!; + } + + /** + * Update bucket + */ + updateBucket(key: string, bucket: RateLimitBucket): void { + this.buckets.set(key, bucket); + } + + /** + * Clean up old entries (older than 1 hour) + */ + private cleanup(): void { + const oneHourAgo = Date.now() - 60 * 60 * 1000; + for (const [key, bucket] of this.buckets.entries()) { + if (bucket.lastRefill < oneHourAgo) { + this.buckets.delete(key); + } + } + } + + /** + * Get store size (for monitoring) + */ + getSize(): number { + return this.buckets.size; + } + + /** + * Clear all buckets (for testing) + */ + clear(): void { + this.buckets.clear(); + } + + /** + * Destroy the store + */ + destroy(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + this.buckets.clear(); + } +} + +/** + * Global rate limit store instance + */ +const rateLimitStore = new RateLimitStore(); + +/** + * Lambda handler type + */ +export type RateLimitWrappedHandler = (event: APIGatewayEvent) => Promise; + +/** + * Default key extractor - uses source IP address + */ +function defaultKeyExtractor(event: APIGatewayEvent): string { + const sourceIp = event.requestContext?.identity?.sourceIp || 'unknown'; + const path = event.path || ''; + return `${sourceIp}:${path}`; +} + +/** + * Check rate limit for a key + */ +function checkRateLimit( + key: string, + config: RateLimitConfig +): { allowed: boolean; retryAfter: number } { + const now = Date.now(); + const bucket = rateLimitStore.getBucket(key, config.maxRequests); + + // Refill tokens based on time elapsed + const timeElapsed = (now - bucket.lastRefill) / 1000; // seconds + const refillRate = config.maxRequests / config.windowSeconds; + const tokensToAdd = timeElapsed * refillRate; + + bucket.tokens = Math.min(config.maxRequests, bucket.tokens + tokensToAdd); + bucket.lastRefill = now; + + // Check if request can be allowed + if (bucket.tokens >= 1) { + bucket.tokens -= 1; + bucket.requestCount += 1; + rateLimitStore.updateBucket(key, bucket); + return { allowed: true, retryAfter: 0 }; + } + + // Calculate retry after time + const tokensNeeded = 1 - bucket.tokens; + const retryAfter = Math.ceil(tokensNeeded / refillRate); + + return { allowed: false, retryAfter }; +} + +/** + * Rate limiting middleware + * Implements token bucket algorithm to limit requests per IP/user + * + * @param config - Rate limit configuration + * @returns Middleware function + * + * @example + * ```typescript + * export const handler = compose( + * rateLimitMiddleware({ maxRequests: 100, windowSeconds: 60 }), + * async (event) => { + * // Handler logic + * return { statusCode: 200, body: '{}' }; + * } + * ); + * ``` + */ +export function rateLimitMiddleware(config: RateLimitConfig) { + const logger = createLogger({ middleware: 'rateLimitMiddleware' }); + + return function (handler: RateLimitWrappedHandler): RateLimitWrappedHandler { + return async (event: APIGatewayEvent): Promise => { + // Skip rate limiting if configured + if (config.skip && config.skip(event)) { + return handler(event); + } + + // Extract key (IP address or custom) + const keyExtractor = config.keyExtractor || defaultKeyExtractor; + const key = keyExtractor(event); + + // Check rate limit + const { allowed, retryAfter } = checkRateLimit(key, config); + + if (!allowed) { + logger.warn('Rate limit exceeded', { + key, + path: event.path, + method: event.httpMethod, + retryAfter, + }); + + const error = new RateLimitError(retryAfter); + return { + statusCode: error.statusCode, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Retry-After': retryAfter.toString(), + 'X-RateLimit-Limit': config.maxRequests.toString(), + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': (Date.now() + retryAfter * 1000).toString(), + }, + body: JSON.stringify(error.toJSON()), + }; + } + + // Add rate limit headers to response + const response = await handler(event); + + // Add rate limit info to headers + return { + ...response, + headers: { + ...response.headers, + 'X-RateLimit-Limit': config.maxRequests.toString(), + 'X-RateLimit-Remaining': Math.floor( + rateLimitStore.getBucket(key, config.maxRequests).tokens + ).toString(), + }, + }; + }; + }; +} + +/** + * Get rate limit store (for testing and monitoring) + */ +export function getRateLimitStore(): RateLimitStore { + return rateLimitStore; +} + +/** + * Clear rate limit store (for testing) + */ +export function clearRateLimitStore(): void { + rateLimitStore.clear(); +} diff --git a/api/src/middleware/validation.ts b/api/src/middleware/validation.ts new file mode 100644 index 0000000..d214d0d --- /dev/null +++ b/api/src/middleware/validation.ts @@ -0,0 +1,428 @@ +/** + * Request Validation Middleware for Lambda Functions + * Validates request body, query parameters, and headers + * Requirements: Input validation and security + * Following SOLID principles: Single Responsibility + */ + +import { APIGatewayEvent, APIGatewayResponse } from '../types/api'; +import { ValidationError } from '../types/errors'; +import { createLogger } from './logging'; + +/** + * Validation rule type + */ +export type ValidationRule = (value: T) => boolean | string; + +/** + * Schema field definition + */ +export interface SchemaField { + /** + * Field type + */ + type: 'string' | 'number' | 'boolean' | 'object' | 'array'; + + /** + * Whether the field is required + */ + required?: boolean; + + /** + * Minimum length (for strings) or value (for numbers) + */ + min?: number; + + /** + * Maximum length (for strings) or value (for numbers) + */ + max?: number; + + /** + * Pattern to match (for strings) + */ + pattern?: RegExp; + + /** + * Allowed values (enum) + */ + enum?: unknown[]; + + /** + * Custom validation rules + */ + rules?: ValidationRule[]; + + /** + * Nested schema (for objects) + */ + schema?: ValidationSchema; + + /** + * Item schema (for arrays) + */ + items?: SchemaField; + + /** + * Custom error message + */ + message?: string; +} + +/** + * Validation schema + */ +export interface ValidationSchema { + [key: string]: SchemaField; +} + +/** + * Validation options + */ +export interface ValidationOptions { + /** + * Validate request body + */ + body?: ValidationSchema; + + /** + * Validate query parameters + */ + query?: ValidationSchema; + + /** + * Validate headers + */ + headers?: ValidationSchema; + + /** + * Allow additional fields not in schema + */ + allowAdditional?: boolean; + + /** + * Strip unknown fields + */ + stripUnknown?: boolean; +} + +/** + * Validation result + */ +interface ValidationResult { + isValid: boolean; + errors: string[]; + value?: unknown; +} + +/** + * Validate value against schema field + */ +function validateField( + fieldName: string, + value: unknown, + field: SchemaField +): { isValid: boolean; error?: string } { + // Check required + if (field.required && (value === undefined || value === null || value === '')) { + return { + isValid: false, + error: field.message || `${fieldName} is required`, + }; + } + + // Allow optional fields + if (!field.required && (value === undefined || value === null)) { + return { isValid: true }; + } + + // Type validation + const actualType = Array.isArray(value) ? 'array' : typeof value; + if (actualType !== field.type && value !== undefined && value !== null) { + return { + isValid: false, + error: field.message || `${fieldName} must be of type ${field.type}`, + }; + } + + // String validation + if (field.type === 'string' && typeof value === 'string') { + if (field.min !== undefined && value.length < field.min) { + return { + isValid: false, + error: field.message || `${fieldName} must be at least ${field.min} characters`, + }; + } + + if (field.max !== undefined && value.length > field.max) { + return { + isValid: false, + error: field.message || `${fieldName} must be at most ${field.max} characters`, + }; + } + + if (field.pattern && !field.pattern.test(value)) { + return { + isValid: false, + error: field.message || `${fieldName} does not match required pattern`, + }; + } + } + + // Number validation + if (field.type === 'number' && typeof value === 'number') { + if (field.min !== undefined && value < field.min) { + return { + isValid: false, + error: field.message || `${fieldName} must be at least ${field.min}`, + }; + } + + if (field.max !== undefined && value > field.max) { + return { + isValid: false, + error: field.message || `${fieldName} must be at most ${field.max}`, + }; + } + } + + // Enum validation + if (field.enum && !field.enum.includes(value)) { + return { + isValid: false, + error: field.message || `${fieldName} must be one of: ${field.enum.join(', ')}`, + }; + } + + // Array validation + if (field.type === 'array' && Array.isArray(value)) { + if (field.items) { + for (let i = 0; i < value.length; i++) { + const itemResult = validateField(`${fieldName}[${i}]`, value[i], field.items); + if (!itemResult.isValid) { + return itemResult; + } + } + } + } + + // Object validation + if (field.type === 'object' && field.schema && typeof value === 'object') { + const nestedResult = validateSchema(value as Record, field.schema, { + allowAdditional: true, + }); + if (!nestedResult.isValid) { + return { + isValid: false, + error: nestedResult.errors.join(', '), + }; + } + } + + // Custom rules + if (field.rules) { + for (const rule of field.rules) { + const result = rule(value); + if (result !== true) { + return { + isValid: false, + error: typeof result === 'string' ? result : field.message || `${fieldName} is invalid`, + }; + } + } + } + + return { isValid: true }; +} + +/** + * Validate data against schema + */ +function validateSchema( + data: Record, + schema: ValidationSchema, + options: { allowAdditional?: boolean; stripUnknown?: boolean } = {} +): ValidationResult { + const errors: string[] = []; + const validated: Record = {}; + + // Validate each field in schema + for (const [fieldName, fieldDef] of Object.entries(schema)) { + const value = data[fieldName]; + const result = validateField(fieldName, value, fieldDef); + + if (!result.isValid) { + errors.push(result.error!); + } else if (value !== undefined) { + validated[fieldName] = value; + } + } + + // Check for additional fields + if (!options.allowAdditional && !options.stripUnknown) { + for (const key of Object.keys(data)) { + if (!(key in schema)) { + errors.push(`Unknown field: ${key}`); + } + } + } + + // Include additional fields if allowed + if (options.allowAdditional || options.stripUnknown) { + for (const [key, value] of Object.entries(data)) { + if (!(key in schema)) { + if (options.allowAdditional) { + validated[key] = value; + } + // If stripUnknown, we simply don't include it + } + } + } + + return { + isValid: errors.length === 0, + errors, + value: validated, + }; +} + +/** + * Lambda handler type + */ +export type ValidationWrappedHandler = (event: APIGatewayEvent) => Promise; + +/** + * Validation middleware + * Validates request data against provided schemas + * + * @param options - Validation options + * @returns Middleware function + * + * @example + * ```typescript + * export const handler = compose( + * validationMiddleware({ + * body: { + * email: { type: 'string', required: true, pattern: /^.+@.+\..+$/ }, + * age: { type: 'number', min: 0, max: 150 } + * } + * }), + * async (event) => { + * // Handler logic - body is validated + * return { statusCode: 200, body: '{}' }; + * } + * ); + * ``` + */ +export function validationMiddleware(options: ValidationOptions) { + const logger = createLogger({ middleware: 'validationMiddleware' }); + + return function (handler: ValidationWrappedHandler): ValidationWrappedHandler { + return async (event: APIGatewayEvent): Promise => { + const validationErrors: string[] = []; + + // Validate body + if (options.body && event.body) { + try { + const bodyData = JSON.parse(event.body); + const result = validateSchema(bodyData, options.body, { + allowAdditional: options.allowAdditional, + stripUnknown: options.stripUnknown, + }); + + if (!result.isValid) { + validationErrors.push(...result.errors.map((e) => `Body: ${e}`)); + } + } catch (error) { + validationErrors.push('Body: Invalid JSON'); + } + } + + // Validate query parameters + if (options.query && event.queryStringParameters) { + const result = validateSchema(event.queryStringParameters, options.query, { + allowAdditional: options.allowAdditional, + stripUnknown: options.stripUnknown, + }); + + if (!result.isValid) { + validationErrors.push(...result.errors.map((e) => `Query: ${e}`)); + } + } + + // Validate headers + if (options.headers && event.headers) { + const result = validateSchema(event.headers, options.headers, { + allowAdditional: true, // Always allow additional headers + stripUnknown: false, + }); + + if (!result.isValid) { + validationErrors.push(...result.errors.map((e) => `Headers: ${e}`)); + } + } + + // If validation failed, return error + if (validationErrors.length > 0) { + logger.warn('Validation failed', { errors: validationErrors }); + + const error = new ValidationError(validationErrors.join('; ')); + return { + statusCode: error.statusCode, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify(error.toJSON()), + }; + } + + // Validation passed, execute handler + return handler(event); + }; + }; +} + +/** + * Common validation rules + */ +export const ValidationRules = { + /** + * Email validation + */ + email: (): ValidationRule => (value: string) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) || 'Must be a valid email address', + + /** + * URL validation + */ + url: (): ValidationRule => (value: string) => { + try { + new URL(value); + return true; + } catch { + return 'Must be a valid URL'; + } + }, + + /** + * UUID validation + */ + uuid: (): ValidationRule => (value: string) => + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value) || + 'Must be a valid UUID', + + /** + * Alphanumeric validation + */ + alphanumeric: (): ValidationRule => (value: string) => + /^[a-zA-Z0-9]+$/.test(value) || 'Must contain only letters and numbers', + + /** + * Custom regex validation + */ + matches: + (pattern: RegExp, message?: string): ValidationRule => + (value: string) => + pattern.test(value) || message || 'Does not match required pattern', +}; diff --git a/api/src/utils/multipart-parser.ts b/api/src/utils/multipart-parser.ts new file mode 100644 index 0000000..038d2fc --- /dev/null +++ b/api/src/utils/multipart-parser.ts @@ -0,0 +1,258 @@ +/** + * Multipart Form Data Parser for Lambda Functions + * Parses multipart/form-data without external dependencies + * Requirements: Enhanced file upload support + * Following SOLID principles: Single Responsibility + */ + +import { ValidationError } from '../types/errors'; + +/** + * Parsed multipart field + */ +export interface MultipartField { + name: string; + value: string; + contentType?: string; +} + +/** + * Parsed multipart file + */ +export interface MultipartFile { + name: string; + filename: string; + contentType: string; + data: Buffer; + size: number; +} + +/** + * Parsed multipart form data + */ +export interface ParsedMultipartData { + fields: Record; + files: MultipartFile[]; +} + +/** + * Parse Content-Disposition header + */ +function parseContentDisposition(header: string): { + name?: string; + filename?: string; +} { + const result: { name?: string; filename?: string } = {}; + + // Match name="value" or name=value patterns + const nameMatch = header.match(/name="?([^";\r\n]+)"?/); + if (nameMatch) { + result.name = nameMatch[1]; + } + + // Match filename="value" or filename=value patterns + const filenameMatch = header.match(/filename="?([^";\r\n]+)"?/); + if (filenameMatch) { + result.filename = filenameMatch[1]; + } + + return result; +} + +/** + * Parse Content-Type header + */ +function parseContentType(header: string): string { + const match = header.match(/^([^;\s]+)/); + return match ? match[1].trim() : 'application/octet-stream'; +} + +/** + * Extract boundary from Content-Type header + */ +export function extractBoundary(contentType: string): string | null { + const match = contentType.match(/boundary=(?:"([^"]+)"|([^;\s]+))/); + return match ? match[1] || match[2] : null; +} + +/** + * Parse multipart form data + * + * @param body - Raw body as Buffer or base64 string + * @param boundary - Multipart boundary string + * @returns Parsed fields and files + * + * @example + * ```typescript + * const boundary = extractBoundary(event.headers['content-type']); + * const parsed = parseMultipartFormData(event.body, boundary); + * ``` + */ +export function parseMultipartFormData( + body: Buffer | string, + boundary: string +): ParsedMultipartData { + if (!boundary) { + throw new ValidationError('Missing boundary in multipart request'); + } + + // Convert to Buffer if needed + const buffer = Buffer.isBuffer(body) ? body : Buffer.from(body, 'base64'); + + const fields: Record = {}; + const files: MultipartFile[] = []; + + // Split by boundary + const boundaryBuffer = Buffer.from(`--${boundary}`); + const parts = splitBuffer(buffer, boundaryBuffer); + + for (const part of parts) { + if (part.length === 0 || part.toString().trim() === '--') { + continue; + } + + // Split headers and body by double CRLF + const headerEndIndex = findSequence(part, Buffer.from('\r\n\r\n')); + if (headerEndIndex === -1) { + continue; + } + + const headersBuffer = part.slice(0, headerEndIndex); + const bodyBuffer = part.slice(headerEndIndex + 4); + + // Parse headers + const headers = parseHeaders(headersBuffer.toString()); + + // Get Content-Disposition + const contentDisposition = headers['content-disposition']; + if (!contentDisposition) { + continue; + } + + const { name, filename } = parseContentDisposition(contentDisposition); + if (!name) { + continue; + } + + // If it has a filename, it's a file + if (filename) { + const contentType = headers['content-type'] + ? parseContentType(headers['content-type']) + : 'application/octet-stream'; + + // Remove trailing CRLF from body + let fileData = bodyBuffer; + if ( + fileData.length >= 2 && + fileData[fileData.length - 2] === 0x0d && + fileData[fileData.length - 1] === 0x0a + ) { + fileData = fileData.slice(0, -2); + } + + files.push({ + name, + filename, + contentType, + data: fileData, + size: fileData.length, + }); + } else { + // It's a regular field + let fieldValue = bodyBuffer.toString('utf-8'); + // Remove trailing CRLF + if (fieldValue.endsWith('\r\n')) { + fieldValue = fieldValue.slice(0, -2); + } + fields[name] = fieldValue; + } + } + + return { fields, files }; +} + +/** + * Parse headers from buffer + */ +function parseHeaders(headerText: string): Record { + const headers: Record = {}; + const lines = headerText.split('\r\n'); + + for (const line of lines) { + const colonIndex = line.indexOf(':'); + if (colonIndex > 0) { + const key = line.slice(0, colonIndex).trim().toLowerCase(); + const value = line.slice(colonIndex + 1).trim(); + headers[key] = value; + } + } + + return headers; +} + +/** + * Split buffer by delimiter + */ +function splitBuffer(buffer: Buffer, delimiter: Buffer): Buffer[] { + const parts: Buffer[] = []; + let start = 0; + let index = findSequence(buffer, delimiter, start); + + while (index !== -1) { + parts.push(buffer.slice(start, index)); + start = index + delimiter.length; + index = findSequence(buffer, delimiter, start); + } + + // Add remaining part + if (start < buffer.length) { + parts.push(buffer.slice(start)); + } + + return parts; +} + +/** + * Find sequence in buffer (Boyer-Moore-like search) + */ +function findSequence(buffer: Buffer, sequence: Buffer, start = 0): number { + if (sequence.length === 0) return -1; + if (start + sequence.length > buffer.length) return -1; + + for (let i = start; i <= buffer.length - sequence.length; i++) { + let found = true; + for (let j = 0; j < sequence.length; j++) { + if (buffer[i + j] !== sequence[j]) { + found = false; + break; + } + } + if (found) return i; + } + + return -1; +} + +/** + * Validate multipart request + */ +export function validateMultipartRequest(contentType?: string): { + isValid: boolean; + boundary?: string; + error?: string; +} { + if (!contentType) { + return { isValid: false, error: 'Missing Content-Type header' }; + } + + if (!contentType.includes('multipart/form-data')) { + return { isValid: false, error: 'Content-Type must be multipart/form-data' }; + } + + const boundary = extractBoundary(contentType); + if (!boundary) { + return { isValid: false, error: 'Missing boundary in Content-Type' }; + } + + return { isValid: true, boundary }; +} diff --git a/web-app/src/components/common/Card.tsx b/web-app/src/components/common/Card.tsx index 81b20de..238068a 100644 --- a/web-app/src/components/common/Card.tsx +++ b/web-app/src/components/common/Card.tsx @@ -66,6 +66,11 @@ export interface ICardProps extends IBaseComponent { * Click handler for the entire card */ onClick?: () => void; + + /** + * ARIA label for accessibility + */ + ariaLabel?: string; } /** @@ -86,6 +91,7 @@ export const Card: React.FC = ({ className, testId, disabled = false, + ariaLabel, }) => { return ( = ({ onClick={onClick} className={className} data-testid={testId} + role={onClick ? 'button' : 'article'} + aria-label={ariaLabel || title} + tabIndex={onClick ? 0 : undefined} + onKeyDown={ + onClick + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + } + : undefined + } sx={{ cursor: onClick ? 'pointer' : 'default', opacity: disabled ? 0.6 : 1, pointerEvents: disabled ? 'none' : 'auto', + '&:focus-visible': onClick + ? { + outline: '2px solid', + outlineColor: 'primary.main', + outlineOffset: '2px', + } + : undefined, }} > {(title || subtitle || headerAction) && (