Files
rentall-app/lambdas/imageProcessor/index.js
2026-01-14 12:11:50 -05:00

162 lines
5.1 KiB
JavaScript

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";
}