```html

Building the Sailor Board: Real-time Event Photo Curation with Lambda, S3, and Instagram Integration

What Was Done

We implemented a complete photo upload and curation system for event guest pages, centered on the "Sailor Board"—a per-event gallery that aggregates guest uploads and same-day Instagram posts matching event hashtags. The system launched with Keely's afternoon charter (2026-05-24) and is now production-ready for all future events.

The build includes:

  • Guest upload UI with multi-file selection (up to 24 at once) and event-code gating to prevent spam
  • Lambda-driven presigned S3 uploads with HEIC/HEIF/video support
  • Automatic thumbnail generation and metadata extraction
  • Same-day Instagram hashtag search and inline gallery rendering
  • Moderation workflow with email notifications and one-click approval links
  • Deep-link "Book a Sail" modal trigger from guest pages

Technical Architecture

Guest Upload Flow

The upload interface lives in /g/{event_id} guest pages. File handling is managed in the HTML form (around line 514-520 in the guest page template):

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

// Accepted MIME types: image/jpeg, image/png, image/webp, image/heic, image/heif, video/mp4, video/quicktime, video/x-msvideo, video/webm

The upload gate is enforced client-side and server-side:

  • No code: Files enter a review queue (moderation required before publication)
  • With event code: Files publish immediately to the gallery

The event code is passed in the `#g-code` input field (line 354) and validated by the Lambda presign endpoint.

Lambda Backend: shipcaptaincrew/lambda_function.py

The core upload handler is split into two main routes:

  • POST /presign — Returns AWS SigV4 presigned URLs for direct S3 upload
  • GET /api/g/{event_id}/photos — Serves the combined guest uploads + Instagram feed for rendering

Presign Endpoint Logic:

  • Accepts event ID, file name, MIME type, and optional event code
  • Validates the code against DynamoDB event record (table: events, key: event_id)
  • If code matches, sets S3 object metadata moderation_status=approved
  • If no code or invalid code, sets moderation_status=pending
  • Returns 15-minute-expiry presigned PUT URL pointing to S3 bucket qos-photos-prod under key pattern: events/{event_id}/{uuid}.{ext}

Photos API Endpoint:

  • Queries DynamoDB table event_photos for all approved photos matching the event ID
  • Fetches Instagram metadata from the `instagram` field in the event record (populated by a separate IG fetcher Lambda)
  • Merges both sources and returns as JSON with photos and instagram arrays
  • Each photo record includes: upload timestamp, uploader name/email, S3 key, thumbnail URL, original dimensions

Thumbnail Generation: Pillow Backfill

Initial photo uploads required retroactive thumbnail generation. We ran a backfill script (/tmp/backfill-thumbs.py) to process existing uploads:

#!/usr/bin/env python3
# Iterates event_photos DDB table
# For each approved photo in S3:
#   - Download original
#   - Generate 300x300 and 800x600 thumbnails via Pillow
#   - Upload thumbs to qos-photos-prod under /thumbs/{uuid}_300.webp and /thumbs/{uuid}_800.webp
#   - Update DDB record with thumb_url_small and thumb_url_large

Lambda runtime: Python 3.11 with Pillow==10.0.0 and boto3 (AWS SDK). Webp encoding is used for all thumbnails to reduce bandwidth without quality loss.

Instagram Integration

A separate scheduled Lambda (invoked ~4 hours before each event start time) queries Instagram's Graph API for posts matching hashtags #jada and #queenofsandiego from the same calendar day:

  • Filters results to UTC timestamps within the 24-hour window
  • Extracts media_type, caption, media_url, permalink, and timestamp
  • Stores as a JSON array in the event record's instagram field (DynamoDB)
  • Keyed by event_id for fast lookup during the photos API call

The guest page renders this as a horizontal scrolling grid (lines 452–459 in the HTML) with attribution links back to Instagram.

Moderation Workflow

Photos uploaded without an event code trigger an SNS notification to moderators. The email (built in Lambda around line 2030–2070) includes:

  • Uploader name and email
  • Event details (date, charter name)
  • Thumbnail preview
  • One-click approve link: https://queenofsandiego.com/admin/approve/{photo_id}?token={jwt}
  • Text: "Your photo is live. Welcome to the sailor board." (once approved)

The approve endpoint updates the DDB record moderation_status=approved, and the photo appears in the guest page gallery on next refresh.

Infrastructure & Deployment

S3 Buckets

  • qos-photos-prod — Photo uploads and thumbnails (public read, private write)
  • queenofsandiego.com — Static site assets including guest page HTML templates

CORS Configuration on qos-photos-prod:

AllowedOrigins: ["https://queenofsandiego.com", "https://staging.queenofsandiego.com"]
AllowedMethods: ["GET", "PUT", "POST"]
AllowedHeaders: ["*"]
MaxAgeSeconds: 3600

This allows the browser to make preflight requests and presigned PUT uploads from guest pages without CORS errors.

Lambda Function Deployment

The shipcaptaincrew function is deployed to AWS Lambda with the following resource spec:

  • Runtime: Python 3.11
  • Memory: 512 MB