127 lines
3.3 KiB
TypeScript
127 lines
3.3 KiB
TypeScript
|
|
import ApiError from './ApiError';
|
||
|
|
|
||
|
|
interface ParsedFormData {
|
||
|
|
fields: Record<string, string>;
|
||
|
|
files: Array<{
|
||
|
|
fieldName: string;
|
||
|
|
fileName: string;
|
||
|
|
contentType: string;
|
||
|
|
data: Buffer;
|
||
|
|
}>;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Parse multipart/form-data from Lambda event
|
||
|
|
* Supports both base64 encoded and binary body
|
||
|
|
*/
|
||
|
|
export function parseMultipartFormData(
|
||
|
|
eventBody: string | null,
|
||
|
|
contentType: string | undefined,
|
||
|
|
isBase64Encoded: boolean = false
|
||
|
|
): ParsedFormData {
|
||
|
|
if (!eventBody) {
|
||
|
|
throw new ApiError(400, 'Request body is required');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!contentType || !contentType.includes('multipart/form-data')) {
|
||
|
|
throw new ApiError(400, 'Content-Type must be multipart/form-data');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Extract boundary from Content-Type header
|
||
|
|
const boundaryMatch = contentType.match(/boundary=([^;]+)/);
|
||
|
|
if (!boundaryMatch) {
|
||
|
|
throw new ApiError(400, 'Invalid multipart boundary');
|
||
|
|
}
|
||
|
|
const boundary = boundaryMatch[1].trim();
|
||
|
|
|
||
|
|
// Decode base64 body if needed (API Gateway sends base64 encoded for binary media types)
|
||
|
|
let bodyBuffer: Buffer;
|
||
|
|
try {
|
||
|
|
if (isBase64Encoded) {
|
||
|
|
bodyBuffer = Buffer.from(eventBody, 'base64');
|
||
|
|
} else {
|
||
|
|
// Try to detect if it's base64
|
||
|
|
if (eventBody.match(/^[A-Za-z0-9+/=]+$/)) {
|
||
|
|
bodyBuffer = Buffer.from(eventBody, 'base64');
|
||
|
|
} else {
|
||
|
|
bodyBuffer = Buffer.from(eventBody, 'binary');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
throw new ApiError(400, 'Invalid request body encoding');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Split by boundary
|
||
|
|
const parts = bodyBuffer.toString('binary').split(`--${boundary}`);
|
||
|
|
|
||
|
|
const fields: Record<string, string> = {};
|
||
|
|
const files: ParsedFormData['files'] = [];
|
||
|
|
|
||
|
|
for (const part of parts) {
|
||
|
|
if (!part || part.trim() === '' || part.trim() === '--') {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Split headers and body
|
||
|
|
const [headers, ...bodyParts] = part.split('\r\n\r\n');
|
||
|
|
if (!headers || bodyParts.length === 0) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const body = bodyParts.join('\r\n\r\n').trim();
|
||
|
|
if (!body) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Parse Content-Disposition header
|
||
|
|
const contentDispositionMatch = headers.match(/Content-Disposition:\s*form-data;\s*name="([^"]+)"/);
|
||
|
|
if (!contentDispositionMatch) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const fieldName = contentDispositionMatch[1];
|
||
|
|
|
||
|
|
// Check if it's a file
|
||
|
|
const filenameMatch = headers.match(/filename="([^"]+)"/);
|
||
|
|
const contentTypeMatch = headers.match(/Content-Type:\s*([^\r\n]+)/);
|
||
|
|
|
||
|
|
if (filenameMatch) {
|
||
|
|
// It's a file
|
||
|
|
const fileName = filenameMatch[1];
|
||
|
|
const fileContentType = contentTypeMatch ? contentTypeMatch[1].trim() : 'application/octet-stream';
|
||
|
|
|
||
|
|
// Convert body to buffer (remove trailing boundary markers)
|
||
|
|
const fileData = Buffer.from(body.replace(/\r\n--$/, ''), 'binary');
|
||
|
|
|
||
|
|
files.push({
|
||
|
|
fieldName,
|
||
|
|
fileName,
|
||
|
|
contentType: fileContentType,
|
||
|
|
data: fileData,
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
// It's a regular field
|
||
|
|
fields[fieldName] = body.replace(/\r\n--$/, '').trim();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return { fields, files };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Parse JSON field from form data
|
||
|
|
*/
|
||
|
|
export function parseJsonField(fields: Record<string, string>, fieldName: string): any {
|
||
|
|
const value = fields[fieldName];
|
||
|
|
if (!value) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
return JSON.parse(value);
|
||
|
|
} catch (error) {
|
||
|
|
throw new ApiError(400, `Invalid JSON in field: ${fieldName}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|