Building the Sailor Board: Photo Upload, Moderation, and Real-Time Instagram Integration for Event Guest Pages
This post covers the architecture, deployment, and feature implementation for Queen of San Diego's guest event pages—specifically the photo upload pipeline, event-scoped moderation, and same-day Instagram hashtag integration that powers the "Sailor Board" experience.
What Was Built
Guest event pages (e.g., /g/2026-05-24-keely-afternoon) now support:
- Multi-file upload (up to 24 at once): JPEG, PNG, WebP, HEIC/HEIF, MP4, MOV, AVI, WebM
- Event code gate: Guests with the event code bypass moderation; uploads without code go to a review queue
- Same-day Instagram feed: Automatic hashtag search (#jada, #queenofsandiego) populated server-side and rendered into the guest page
- Thumbnail generation: Automatic image optimization and thumb backfill for existing uploads
- Modal booking integration: "Book a Sail" now opens the Stripe checkout modal inline instead of navigating away
Technical Architecture
Frontend: Guest Page Structure
Guest pages are static HTML deployed to S3 at paths like:
/g/{YYYY-MM-DD}-{event-slug}/index.html
For Keely's charter (May 24, 2026), the file is located at:
s3://queenofsandiego-prod/g/2026-05-24-keely-afternoon/index.html
The page is a self-contained HTML document with embedded JavaScript and CSS. Key DOM elements:
#file-input(line 362): Multi-file upload input, accepts all supported media types#g-code(line 354): Event code input for moderation bypass#photo-grid(lines 452–459): Container for both uploaded photos and Instagram feed#upload-status(line 350): Real-time feedback during upload (validation, progress, success/error)
The page implements a state machine driven by FLIP_UTC (event flip time). Before the flip, the page shows event details; after the flip, the upload UI appears. For Keely's event:
FLIP_UTC = "2026-05-25T00:00:00Z" // May 24, 5 PM PT
Upload Handler: Lambda + S3 Presigned URLs
File uploads use AWS Lambda to generate presigned URLs, avoiding hard-coded S3 credentials in the browser. The flow:
- Frontend calls
GET /api/g/{event_id}/presignwith filename and MIME type - Lambda validates the event code (if provided) against DynamoDB
- Lambda generates a time-limited presigned URL (15-minute expiry)
- Frontend uploads directly to S3 using the presigned URL
- S3 POST triggers a Lambda event processor
Lambda handler: tools/shipcaptaincrew/lambda_function.py, function name presign_guest_upload (approx. line 1400).
Key validation:
- Event ID format:
/g/{YYYY-MM-DD}-{slug}regex - Event code check: Query DynamoDB table
qos-events, key{event_id}, attributeguest_code - MIME type whitelist: Enforced on presigned URL policy and re-validated on S3 POST
S3 bucket for uploads: s3://qos-guest-uploads-prod/
Photo Processing: Moderation Queue and Thumbnails
When a file lands in S3, an object creation event triggers a second Lambda function:
tools/shipcaptaincrew/lambda_function.py::process_guest_photo
This function:
- Pulls event metadata from DynamoDB to determine if the uploader provided a valid code
- Generates thumbnails (48px, 240px, 480px) using Pillow, stores alongside original in S3
- Routes to moderation or live:
- If code is valid: Adds photo metadata to DynamoDB
qos-guest-photoswithstatus = "live" - If no code: Creates entry with
status = "pending", sends Slack notification to moderation channel
- If code is valid: Adds photo metadata to DynamoDB
- Invalidates CloudFront cache for the guest page and photo API endpoints
DynamoDB table schema for photos (qos-guest-photos):
PK: event_id (String)
SK: photo_id (String)
Attributes:
- s3_key (String): path to original file
- thumb_48 (String): presigned URL or S3 key
- thumb_240 (String): presigned URL or S3 key
- status (String): "live" | "pending" | "rejected"
- uploaded_at (Number): Unix timestamp
- uploader_name (String): optional metadata
Thumbnail backfill for existing photos without thumbs is handled by a separate utility script:
/tmp/backfill-thumbs.py
This script queries DynamoDB, downloads missing thumb sizes, uploads to S3, and updates metadata.
Instagram Integration: Server-Side Hashtag Search
The guest page includes an Instagram feed fetched at page-render time (not client-side). The Lambda endpoint:
GET /api/g/{event_id}/photos
Handler: tools/shipcaptaincrew/lambda_function.py::get_guest_photos (approx. line 1600).
This endpoint returns a JSON object with two arrays:
{
"photos": [ /* user-uploaded photos, filtered by status */ ],
"instagram": [
{
"url": "https://instagram.com/...",
"thumb": "https://...",
"caption": "...",
"posted_at": "2026-05-24T14:30:00Z"
},
...
]
}
Instagram hashtag search is performed by querying an Instagram Basic Display API endpoint (or a cached fetch) with hashtags #jada and #queenofsandiego, filtered to posts made on the event date. The actual Instagram credential and API setup are managed outside the Lambda handler (likely as environment variables or Secrets Manager).
The guest page JSON-embeds this response and renders it into #ig-grid at page load: