const { S3Client, GetObjectCommand, PutObjectCommand, DeleteObjectCommand, } = require("@aws-sdk/client-s3"); const { extractMetadata, stripMetadata, getMimeType } = require("./imageProcessor"); const { saveImageMetadata, updateProcessingStatus } = require("./queries"); const { logger } = require("../shared"); const s3Client = new S3Client({ region: process.env.AWS_REGION }); /** * Lambda handler for S3 image processing. * Triggered by S3 ObjectCreated events on staging/ prefix. * * @param {Object} event - S3 event * @returns {Object} Processing results */ exports.handler = async (event) => { logger.info("Lambda invoked", { recordCount: event.Records?.length }); const results = []; for (const record of event.Records) { const bucket = record.s3.bucket.name; const stagingKey = decodeURIComponent(record.s3.object.key.replace(/\+/g, " ")); logger.info("Processing image", { bucket, stagingKey }); try { // Only process files in staging/ folder if (!stagingKey.startsWith("staging/")) { logger.info("Skipping non-staging key", { stagingKey }); results.push({ key: stagingKey, status: "skipped", reason: "not in staging" }); continue; } // Calculate final key: staging/items/uuid.jpg -> items/uuid.jpg const finalKey = stagingKey.replace(/^staging\//, ""); // Check if this is an image file if (!isImageFile(stagingKey)) { logger.info("Skipping non-image file", { stagingKey }); results.push({ key: stagingKey, status: "skipped", reason: "not an image" }); continue; } // Process the image await processImage(bucket, stagingKey, finalKey); results.push({ key: finalKey, status: "success" }); logger.info("Successfully processed image", { finalKey }); } catch (error) { logger.error("Error processing image", { stagingKey, error: error.message, stack: error.stack }); results.push({ key: stagingKey, status: "error", error: error.message }); // Try to update status to failed if we have a finalKey try { const finalKey = stagingKey.replace(/^staging\//, ""); await updateProcessingStatus(finalKey, "failed", error.message); } catch (dbError) { logger.error("Failed to update error status in DB", { error: dbError.message }); } } } return { statusCode: 200, body: JSON.stringify({ processed: results.length, results }), }; }; /** * Process a single image: extract metadata, strip, and move to final location. * * @param {string} bucket - S3 bucket name * @param {string} stagingKey - Staging key (e.g., staging/items/uuid.jpg) * @param {string} finalKey - Final key (e.g., items/uuid.jpg) */ async function processImage(bucket, stagingKey, finalKey) { // 1. Download image from staging location logger.debug("Downloading from staging", { stagingKey }); const getCommand = new GetObjectCommand({ Bucket: bucket, Key: stagingKey, }); const response = await s3Client.send(getCommand); const buffer = Buffer.from(await response.Body.transformToByteArray()); // 2. Extract metadata BEFORE stripping logger.debug("Extracting metadata"); const metadata = await extractMetadata(buffer); logger.info("Extracted metadata", { finalKey, metadata }); // 3. Save metadata to database logger.debug("Saving metadata to DB", { finalKey }); await saveImageMetadata(finalKey, metadata); // 4. Strip metadata from image logger.debug("Stripping metadata"); const strippedBuffer = await stripMetadata(buffer); // 5. Determine content type from original const format = stagingKey.split(".").pop().toLowerCase(); const contentType = getMimeType(format); // 6. Upload clean image to FINAL location logger.debug("Uploading to final location", { finalKey }); const putCommand = new PutObjectCommand({ Bucket: bucket, Key: finalKey, Body: strippedBuffer, ContentType: contentType, CacheControl: getCacheControl(finalKey), Metadata: { "x-processed": "true", "x-processed-at": new Date().toISOString(), }, }); await s3Client.send(putCommand); // 7. Delete staging file logger.debug("Deleting staging file", { stagingKey }); const deleteCommand = new DeleteObjectCommand({ Bucket: bucket, Key: stagingKey, }); await s3Client.send(deleteCommand); // 8. Update processing status to completed logger.debug("Updating processing status to completed"); await updateProcessingStatus(finalKey, "completed"); } /** * Check if a file is an image based on extension. * * @param {string} key - S3 key * @returns {boolean} */ function isImageFile(key) { const imageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"]; const ext = key.toLowerCase().slice(key.lastIndexOf(".")); return imageExtensions.includes(ext); } /** * Get Cache-Control header based on folder. * * @param {string} key - S3 key * @returns {string} */ function getCacheControl(key) { // Private folders get shorter cache if (key.startsWith("messages/") || key.startsWith("condition-checks/")) { return "private, max-age=3600"; } // Public folders get longer cache return "public, max-age=86400"; }