162 lines
5.1 KiB
JavaScript
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";
|
|
}
|