```html

Building the Real-Time Photo Moderation Pipeline for Charter Guest Pages

Over the past development session, we built out the complete infrastructure to support photo uploads, moderation, and live display on per-charter guest pages—the "Sailor Board" feature. This post walks through the architecture decisions, Lambda function changes, S3 configuration, and the specific deployment strategy we used to ship this without disrupting live events.

What We Built

The Sailor Board is the photo gallery that appears on each charter's guest page (e.g., /g/2026-05-24-keely-afternoon). Guests can upload up to 24 photos/videos in a single batch, and those uploads flow through a moderation pipeline before being displayed live. The system:

  • Accepts photo and video uploads from guests via a presigned S3 POST policy
  • Validates uploads against an event-specific code (or routes to moderation queue if unauthenticated)
  • Generates thumbnails for gallery display
  • Integrates Instagram posts from the same day matching #jada or #queenofsandiego hashtags
  • Notifies moderators via email for code-less uploads
  • Displays approved photos live in the charter's gallery

Lambda Function Architecture

The core logic lives in /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py. This single Lambda function handles multiple HTTP routes via API Gateway, dispatched by the path parameter:

GET  /api/g/{event_id}/photos          → Fetch approved photos + IG feed for display
POST /api/g/{event_id}/presign         → Generate S3 presigned POST policy
POST /api/g/{event_id}/upload-webhook  → Handle S3 event notifications
GET  /api/moderate/{event_id}          → Moderator dashboard
POST /api/moderate/{event_id}/approve  → Approve photo for display

Each route handler checks for the X-Forwarded-For header (API Gateway injects the client IP) and enforces rate limits on presign and upload endpoints. The function is deployed as a standard AWS Lambda with a 900-second timeout to handle batch thumbnail generation.

S3 Bucket Layout and CORS

We store all uploads in a single bucket with event-based prefixes:

s3://qos-guest-photos/
  └─ 2026-05-24-keely-afternoon/
      ├─ originals/
      │   ├─ {uuid}.jpg
      │   └─ {uuid}.mp4
      ├─ thumbs/
      │   └─ {uuid}_thumb_400.jpg
      └─ metadata/
          └─ photos.json

A critical detail: we initially deployed without updating the S3 CORS policy, which broke presigned POST requests from the browser. The fix was to add the production CloudFront domain (queenofsandiego.com) and staging CloudFront alias to the AllowedOrigins list:

[
  {
    "AllowedMethods": ["GET", "PUT", "POST"],
    "AllowedOrigins": [
      "https://queenofsandiego.com",
      "https://staging-qos.cloudfront.net"
    ],
    "AllowedHeaders": ["*"],
    "MaxAgeSeconds": 3000
  }
]

This ensures the browser's CORS preflight succeeds before the POST upload to the presigned URL.

Presigned POST Policy Generation

Rather than use Python's boto3 client-side signature generation (which would require AWS credentials in the browser), we use the Lambda to generate a presigned POST policy:

response = s3_client.generate_presigned_post(
    Bucket='qos-guest-photos',
    Key=f'{event_id}/originals/{uuid}.{ext}',
    ExpiresIn=3600,
    Conditions=[
        {'Content-Length-Range': [0, 104857600]},  # 100 MB max
        {'acl': 'private'}
    ]
)

The response includes the form's action URL, hidden fields, and signature—the guest-page JavaScript then posts the file directly to S3 without it ever touching our Lambda. This keeps bandwidth costs low and offloads the upload burden to S3.

Thumbnail Generation Pipeline

The trickiest part: generating thumbnails fast enough that the gallery can display them within seconds of upload. We use Python's Pillow library (bundled in the Lambda deployment package) to resize images to 400px width:

from PIL import Image
img = Image.open(BytesIO(object_data))
img.thumbnail((400, 400), Image.Resampling.LANCZOS)
thumb_bytes = BytesIO()
img.save(thumb_bytes, format='JPEG', quality=75)
s3_client.put_object(
    Bucket='qos-guest-photos',
    Key=f'{event_id}/thumbs/{photo_id}_thumb_400.jpg',
    Body=thumb_bytes.getvalue(),
    ContentType='image/jpeg'
)

Videos are skipped (no thumbnail generation), but S3 event notifications trigger the Lambda within milliseconds of upload, so the full metadata file is updated before the browser polls for new photos (polling interval: 2 seconds during active upload).

Deployment Strategy: No Downtime on Live Events

The biggest risk: Keely's charter sailed on 2026-05-24, and we deployed the Lambda code on 2026-05-25 at 02:11 UTC—after the event ended, but while the guest page was live. To avoid disrupting active uploads:

  1. Snapshot the current prod Lambda code to a local file before making changes
  2. Validate syntax with Python's AST parser before deploying
  3. Build a deployment ZIP with all dependencies (boto3, Pillow, py_vapid for push notifications)
  4. Test the presign endpoint directly against prod before invalidating CloudFront caches
  5. Invalidate staging CloudFront first, then prod, to catch any cache issues in the intermediate environment

Commands used:

# Extract current prod code
aws s3 cp s3://qos-lambda-artifacts/shipcaptaincrew-latest.zip /tmp/prod-backup.zip

# Validate new code
python3 -m py_compile lambda_function.py

# Build and deploy
zip -r shipcaptaincrew-deploy.zip lambda_function.py lib/

aws lambda update-function-code \
  --function-name shipcaptaincrew-api \
  --zip-file fileb://shipcaptaincrew-deploy.zip

# Smoke test
curl -X GET "https://api.shipcaptaincrew.queenofsandiego.com/api/g/2026-05-24-keely-afternoon/photos"

# Invalidate cache
aws cloudfront create-invalidation \
  --distribution-id {STAGING_DIST_ID} \
  --paths "/*"

Guest Page HTML and Deep-Linking

The guest page itself (/sailor-board/index.html) is a static HTML template that gets deployed to S3 with per-event variants