Building the Sailor Board: Real-Time Guest Photo Curation & Instagram Integration for Charter Events
This post walks through the architecture, deployment strategy, and design decisions behind Queen of San Diego's guest photo upload system — specifically the "Sailor Board" feature that lets charter guests instantly publish photos to a per-event gallery, with optional Instagram hashtag integration and server-side moderation.
What Was Built
The Sailor Board is a per-event photo gallery living at /g/{event_id} on the public website. It serves three core functions:
- Guest photo upload: Charter attendees upload up to 24 photos/videos at once without authentication
- Event code gating: An optional per-event code allows instant publication; uploads without the code go into a moderation queue
- Instagram hashtag aggregation: Server-side Lambda fetches same-day posts tagged
#jadaor#queenofsandiegoand displays them alongside guest uploads
Live example: https://queenofsandiego.com/g/2026-05-24-keely-afternoon (post-event gallery for the May 24 Keely charter).
Architecture Overview
Frontend: Per-Event Guest Page
The guest page is a static HTML file deployed to S3 and served through CloudFront. Each charter gets its own URL slug based on event date and name:
s3://queenofsandiego.com/g/2026-05-24-keely-afternoon/index.html
The page contains:
- Pre-sail state (before event UTC flip time): Marketing copy, event details, "Book a Sail" button
- Post-sail state (after flip time): File upload form, event code input, photo grid
The flip time is defined in the HTML as FLIP_UTC (e.g., new Date('2026-05-25T00:00:00Z')) and controls when the upload UI becomes visible.
File Upload Flow
When a guest selects files (up to 24 at once), the client-side handleFiles() function (line 514 in the guest page) calls:
GET /api/presign?event_id=2026-05-24-keely-afternoon&filename=photo.jpg
This endpoint, hosted on shipcaptaincrew.queenofsandiego.com, returns a signed S3 URL valid for 15 minutes. The guest's browser then uploads directly to S3, bypassing the Lambda:
PUT {signed_url} + file bytes
On successful upload, a CloudWatch event or S3 trigger invokes the shipcaptaincrew Lambda, which:
- Validates the event code (if provided)
- Extracts EXIF metadata (timestamp, location)
- Generates thumbnail images using Pillow
- Stores metadata in DynamoDB (
PhotoMetadatatable) - Publishes to a moderation queue (SNS) if no valid code was supplied
Backend: Lambda Handlers
All backend logic lives in /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py. Key handlers:
presign_handler()— Generates signed S3 upload URLsprocess_upload()— Triggered on S3 PUT; validates code, extracts metadata, generates thumbnailsget_event_photos()— Returns JSON list of approved photos + Instagram feed for a givenevent_idmoderate_photo()— Admin endpoint to approve/reject uploads
Instagram Integration
The get_event_photos() handler queries Instagram's Graph API for posts matching the event date and hashtags:
GET https://graph.instagram.com/v18.0/ig_hashtag_search?user_id={user_id}&fields=id,name
GET https://graph.instagram.com/v18.0/{hashtag_id}/recent_media?fields=id,caption,media_type,media_url&limit=20
Results are filtered to same-day posts only and merged into the response JSON under the instagram key. The guest page renders these in a separate grid section, visually distinguished from guest uploads.
Why server-side? Instagram's client-side SDK doesn't expose hashtag search; we needed backend access to the Graph API with a long-lived app token.
Infrastructure: S3, DynamoDB, Lambda
S3 Buckets
queenofsandiego.com— Public site bucket; serves the guest pages and final photosqueenofsandiego.com-uploads-staging— Staging environment uploads (for testing before prod)queenofsandiego.com-photos— Raw uploads land here temporarily; moved to public bucket after processing
DynamoDB Table: PhotoMetadata
Schema:
PK: event_id(e.g., "2026-05-24-keely-afternoon")SK: photo_key(S3 object key, e.g., "g/2026-05-24-keely-afternoon/photo_123.jpg")- Attributes:
guest_name,guest_code_valid,timestamp,exif_location,status(approved/pending/rejected),thumb_small_url,thumb_large_url
Why DynamoDB? Event-keyed access patterns; no complex joins; scales to thousands of photos per event without hot partitions.
Lambda Configuration
Function: shipcaptaincrew
- Runtime: Python 3.11
- Memory: 1024 MB (required for Pillow thumbnail generation)
- Timeout: 60 seconds
- Layers:
python-pillow(custom layer with compiled Pillow + libjpeg + libpng) - Env vars:
INSTAGRAM_APP_ID,INSTAGRAM_APP_TOKEN(in Secrets Manager, not inline)
API Gateway Routes
Endpoint