11
lambda/config.py
Normal file
11
lambda/config.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Configuration constants for image processor"""
|
||||
|
||||
ALLOWED_FORMATS = {'JPEG', 'JPG', 'PNG', 'WEBP'}
|
||||
MAX_DIMENSION = 4096
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
|
||||
RESIZE_TARGET = (1024, 1024)
|
||||
THUMBNAIL_TARGET = (200, 200)
|
||||
|
||||
DYNAMODB_TTL_DAYS = 90
|
||||
DYNAMODB_TTL_SECONDS = DYNAMODB_TTL_DAYS * 24 * 60 * 60 # 7776000
|
||||
74
lambda/image_processor.py
Normal file
74
lambda/image_processor.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Image processing operations"""
|
||||
import io
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from PIL import Image
|
||||
|
||||
from config import (
|
||||
ALLOWED_FORMATS, MAX_DIMENSION, MAX_FILE_SIZE,
|
||||
RESIZE_TARGET, THUMBNAIL_TARGET
|
||||
)
|
||||
|
||||
|
||||
def validate_image(img_data: bytes) -> tuple[Image.Image, str]:
|
||||
"""Validate and open image data"""
|
||||
if len(img_data) > MAX_FILE_SIZE:
|
||||
raise ValueError(f'File too large: {len(img_data)} bytes')
|
||||
|
||||
img_hash = hashlib.sha256(img_data).hexdigest()
|
||||
img = Image.open(io.BytesIO(img_data))
|
||||
|
||||
if img.format not in ALLOWED_FORMATS:
|
||||
raise ValueError(f'Invalid format: {img.format}')
|
||||
|
||||
if img.width * img.height > MAX_DIMENSION ** 2:
|
||||
raise ValueError(f'Image too large: {img.width}x{img.height}')
|
||||
|
||||
return img, img_hash
|
||||
|
||||
|
||||
def determine_processing(filename: str) -> tuple[tuple[int, int], str]:
|
||||
"""Determine processing type based on filename"""
|
||||
fn = filename.lower()
|
||||
if '_thumb' in fn:
|
||||
return THUMBNAIL_TARGET, 'resize_200x200'
|
||||
elif '_grayscale' in fn:
|
||||
return None, 'grayscale'
|
||||
else:
|
||||
return RESIZE_TARGET, 'resize_1024x1024'
|
||||
|
||||
|
||||
def process_image(img: Image.Image, target: tuple[int, int], ptype: str) -> Image.Image:
|
||||
"""Apply image processing transformations"""
|
||||
if ptype == 'grayscale':
|
||||
return img.convert('L')
|
||||
elif target:
|
||||
img.thumbnail(target, Image.Resampling.LANCZOS)
|
||||
return img
|
||||
|
||||
|
||||
def save_image(img: Image.Image, original_format: str) -> tuple[bytes, str]:
|
||||
"""Save processed image to bytes"""
|
||||
output = io.BytesIO()
|
||||
fmt = original_format or 'JPEG'
|
||||
img.save(output, format=fmt, quality=85)
|
||||
return output.getvalue(), f'image/{fmt.lower()}'
|
||||
|
||||
|
||||
def get_processed_key(original_key: str) -> str:
|
||||
"""Generate processed object key"""
|
||||
return original_key.replace('uploads/', 'processed/')
|
||||
|
||||
|
||||
def build_result(original_key: str, processed_key: str, orig_size: tuple,
|
||||
img: Image.Image, ptype: str, img_hash: str) -> dict:
|
||||
"""Build processing result dictionary"""
|
||||
return {
|
||||
'status': 'success',
|
||||
'original_size': orig_size,
|
||||
'processed_size': img.size,
|
||||
'processing_type': ptype,
|
||||
'processed_key': processed_key,
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'hash': img_hash
|
||||
}
|
||||
52
lambda/lambda_function.py
Normal file
52
lambda/lambda_function.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""AWS Lambda Image Processor - Security Hardened"""
|
||||
import os
|
||||
from image_processor import (
|
||||
validate_image, determine_processing, process_image,
|
||||
save_image, get_processed_key, build_result
|
||||
)
|
||||
from storage import write_metadata, upload_processed, get_object
|
||||
from notifications import send_notification
|
||||
|
||||
BUCKET = os.environ.get('S3_BUCKET', '')
|
||||
TABLE = os.environ['DYNAMODB_TABLE']
|
||||
TOPIC = os.environ['SNS_TOPIC_ARN']
|
||||
ENV = os.environ.get('ENVIRONMENT', 'prod')
|
||||
|
||||
|
||||
def lambda_handler(event: dict, context) -> dict:
|
||||
"""Main Lambda handler for image processing"""
|
||||
for r in event.get('Records', []):
|
||||
bucket = r['s3']['bucket']['name']
|
||||
key = r['s3']['object']['key']
|
||||
|
||||
if not key.startswith('uploads/'):
|
||||
continue
|
||||
|
||||
try:
|
||||
filename = os.path.basename(key)
|
||||
|
||||
# Get and validate image
|
||||
img_data, size = get_object(bucket, key)
|
||||
img, img_hash = validate_image(img_data)
|
||||
|
||||
# Process image
|
||||
target, ptype = determine_processing(filename)
|
||||
img = process_image(img, target, ptype)
|
||||
|
||||
# Save and upload
|
||||
output_data, content_type = save_image(img, img.format)
|
||||
processed_key = get_processed_key(key)
|
||||
upload_processed(bucket, processed_key, output_data, content_type,
|
||||
{'original_hash': img_hash, 'processed_by': 'image-processor'})
|
||||
|
||||
# Build result and store metadata
|
||||
result = build_result(key, processed_key, img.size, img, ptype, img_hash)
|
||||
write_metadata(filename, os.path.basename(processed_key), result)
|
||||
|
||||
send_notification(filename, result, 'success')
|
||||
|
||||
except Exception as e:
|
||||
send_notification(key, {'error': str(e)}, 'error')
|
||||
raise
|
||||
|
||||
return {'statusCode': 200}
|
||||
24
lambda/notifications.py
Normal file
24
lambda/notifications.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Notification operations for SNS"""
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
import boto3
|
||||
|
||||
sns = boto3.client('sns')
|
||||
TOPIC = os.environ['SNS_TOPIC_ARN']
|
||||
ENV = os.environ.get('ENVIRONMENT', 'prod')
|
||||
|
||||
|
||||
def send_notification(filename: str, result: dict, status: str) -> None:
|
||||
"""Send SNS notification"""
|
||||
sns.publish(
|
||||
TopicArn=TOPIC,
|
||||
Subject=f'Image Processing {status.title()}',
|
||||
Message=json.dumps({
|
||||
'filename': filename,
|
||||
'status': status,
|
||||
'details': result,
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'environment': ENV
|
||||
}, indent=2)
|
||||
)
|
||||
1
lambda/requirements.txt
Normal file
1
lambda/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
Pillow==10.4.0
|
||||
46
lambda/storage.py
Normal file
46
lambda/storage.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Storage operations for DynamoDB and S3"""
|
||||
import os
|
||||
import time
|
||||
import boto3
|
||||
|
||||
from config import DYNAMODB_TTL_SECONDS
|
||||
|
||||
dynamodb = boto3.resource('dynamodb')
|
||||
s3 = boto3.client('s3')
|
||||
|
||||
TABLE = os.environ['DYNAMODB_TABLE']
|
||||
ENV = os.environ.get('ENVIRONMENT', 'prod')
|
||||
|
||||
|
||||
def write_metadata(filename: str, processed_filename: str, result: dict) -> None:
|
||||
"""Write processing metadata to DynamoDB"""
|
||||
dynamodb.Table(TABLE).put_item(Item={
|
||||
'filename': filename,
|
||||
'processed_filename': processed_filename,
|
||||
'timestamp': result['timestamp'],
|
||||
'processing_type': result['processing_type'],
|
||||
'status': result['status'],
|
||||
'original_size': str(result['original_size']),
|
||||
'processed_size': str(result['processed_size']),
|
||||
'hash': result.get('hash', ''),
|
||||
'ttl': int(time.time()) + DYNAMODB_TTL_SECONDS,
|
||||
'environment': ENV
|
||||
})
|
||||
|
||||
|
||||
def upload_processed(bucket: str, key: str, data: bytes, content_type: str,
|
||||
metadata: dict) -> None:
|
||||
"""Upload processed image to S3"""
|
||||
s3.put_object(
|
||||
Bucket=bucket,
|
||||
Key=key,
|
||||
Body=data,
|
||||
ContentType=content_type,
|
||||
Metadata=metadata
|
||||
)
|
||||
|
||||
|
||||
def get_object(bucket: str, key: str) -> tuple[bytes, int]:
|
||||
"""Get object from S3"""
|
||||
obj = s3.get_object(Bucket=bucket, Key=key)
|
||||
return obj['Body'].read(), obj['ContentLength']
|
||||
Reference in New Issue
Block a user