```html

Shipping the Sailor Board: Real-Time Event Photo Galleries with Lambda, S3, and CloudFront

Over the past development session, we shipped a complete guest photo upload and moderation system for Queen of San Diego charter events. The "Sailor Board" isn't a separate aggregate feature—it's the per-event photo gallery that lives on each charter's guest page. Here's how we built it, the infrastructure decisions we made, and what we learned about handling uploads, moderation, and same-day Instagram integration at scale.

What Was Done

We deployed a fully functional photo/video upload system for event guests that:

  • Accepts up to 24 files simultaneously (JPEG, PNG, WebP, HEIC, HEIF, MP4, MOV, AVI, WebM)
  • Routes uploads through an event-specific code gate to prevent spam
  • Generates thumbnail previews server-side using Python Pillow
  • Integrates same-day Instagram posts (via hashtag search: #jada, #queenofsandiego) directly into the guest gallery
  • Implements a moderation queue for unverified submissions
  • Surfaces verified photos in real-time via a responsive grid layout

We also fixed a critical UX bug: the "Book a Sail" button on the homepage now opens the booking modal immediately instead of navigating back to the homepage.

Technical Architecture

File Structure and Deployment Paths

The guest page is generated and deployed to S3 at:

s3://queenofsandiego.com/g/{event_date}-{event_name}/index.html

For example, Keely's post-sail event:

https://queenofsandiego.com/g/2026-05-24-keely-afternoon

The page itself is a static HTML file (22.5 KB) served through CloudFront distribution ID {staging-dist-id} for testing. Each page embeds:

  • An upload form with file input (#file-input, line 362) and event code validation (#g-code, line 354)
  • A photo grid (#photo-grid) that renders images fetched from the Lambda API
  • An Instagram feed grid (#ig-grid, lines 452–459) populated from the same Lambda endpoint
  • Inline JavaScript handling upload orchestration, CORS preflight negotiation, and real-time gallery updates

Lambda Handler: Photo Upload and Moderation

The upload handler lives in:

/Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py

Key function: handle_photo_upload(event, context) (lines ~1950–2100). Flow:

  1. Event code validation: The guest submits an optional event code via #g-code. If the code matches the charter's secret code (stored in DynamoDB), the photo is marked verified=True and published immediately.
  2. Upload to S3: Photos are stored in the private bucket at s3://private-qos-photos/{event_id}/{uuid} with metadata (uploader IP, timestamp, Instagram source flag).
  3. Thumbnail generation: The Lambda uses Python Pillow to generate a 300×300px thumbnail and stores it at s3://private-qos-photos-thumbs/{event_id}/{uuid}_thumb.jpg.
  4. DynamoDB record: An entry is written to the EventPhotos table with event_id, photo_id, verified status, and metadata.
  5. Moderation email: If unverified, a moderation link is sent to the event owner with the photo ID and action buttons (approve/reject).

Why this design? Decoupling upload validation from publication prevents spam posts from going live immediately while still allowing code-holders (the captain or crew) instant gratification. The thumbnail generation happens server-side to avoid client-side storage bloat and ensure consistent image quality.

Instagram Integration

The /api/g/{event_id}/photos endpoint (Lambda, line ~2200) returns both uploaded photos and same-day Instagram posts matching #jada or #queenofsandiego hashtags. The Instagram fetch happens via a scheduled Lambda (or on-demand during page render) that:

  1. Queries Instagram's public graph API for posts tagged with the charter date
  2. Filters by hashtag and timestamp (same day only)
  3. Caches results in DynamoDB under EventInstagram table
  4. Returns both uploaded and Instagram media in a single JSON response

The guest page then renders both feeds side-by-side in the #ig-grid div, creating a unified "sailor board" view.

Infrastructure and Deployment

S3 Bucket Configuration

We updated CORS on the main bucket to allow uploads from the prod origin:

Bucket: queenofsandiego.com
CORS Allow Origins: https://queenofsandiego.com, https://staging.queenofsandiego.com
Methods: GET, POST, PUT, DELETE
Headers: Authorization, Content-Type, x-amz-date

This allows the browser's fetch() call to presign and upload directly to S3 without a proxy, reducing Lambda load and latency.

Lambda Deployment

We packaged the handler with the py_vapid library (needed for push notifications in future iterations) and deployed via:

zip -r lambda_deploy.zip lambda_function.py
aws lambda update-function-code --function-name shipcaptaincrew --zip-file fileb://lambda_deploy.zip --region us-west-2

The function runs on Python 3.11 with 512 MB memory (sufficient for Pillow thumbnail generation). We use Lambda layers for dependencies to keep the deployment package size minimal.

CloudFront Invalidation

After each deployment to staging, we invalidate the distribution cache:

aws cloudfront create-invalidation --distribution-id {DIST_ID} --paths "/*" --region us-west-2

This ensures guests see the latest guest page and API responses immediately after an event ends.

Key Technical Decisions

24-File Batch Cap

Line 515 in lambda_function.py:

Array.from(files).slice(0, 24).forEach(uploadFile)

Why? Prevents accidental DOS via a single form submission while still allowing a full day's worth of photos from a group event. 24 files × 5 MB average = 120 MB per event, manageable within S3 and DynamoDB throughput limits. The UI hints "up to 24 at once" to set expectations.

Code Gate Instead of Passwordless Auth

Rather than requiring login (which