Building the Post-Sail Photo Submission Flow: Guest Pages, Real-Time IG Integration, and the Sailor Board Pattern
Over the past development session, we shipped a complete post-event photo collection system for Queen of San Diego charter guests. This article walks through the architecture, the why behind key decisions, and how the pieces fit together—from guest page deployment to Lambda handlers to real-time Instagram hashtag ingestion.
What We Built
The core deliverable is a guest-specific event page that:
- Accepts photo and video uploads from event attendees (up to 24 files simultaneously)
- Gates submissions with an event code to prevent spam
- Automatically surfaces Instagram posts from the same day using
#jadaand#queenofsandiegohashtags - Displays submitted content in a real-time gallery ("sailor board")
- Routes moderated uploads to an email review queue for the charter host
We deployed this first for Keely's May 24 afternoon charter at https://queenofsandiego.com/g/2026-05-24-keely-afternoon and simultaneously built out the supporting infrastructure to scale it across future events.
The Guest Page Anatomy
File location: /Users/cb/Documents/repos/sites/queenofsandiego.com/sailor-board/index.html (template) and deployed to S3 at s3://queenofsandiego.com/g/{event_id}/index.html
The guest page is a self-contained HTML bundle with embedded JavaScript and CSS. Key responsibilities:
- Pre/post-sail state toggle: The page checks a
FLIP_UTCtimestamp (line 447 in the HTML). Before the sail ends, guests see a countdown and teaser. After, the full upload UI appears. For Keely's afternoon charter, this flipped at May 25 00:00 UTC (5 PM PT on May 24). - File input handler: The
handleFiles()function (line 514) accepts JPEG, PNG, WebP, HEIC/HEIF images and MP4, MOV, AVI, WebM videos. Files are sliced to the first 24 and sent viaFormDatamultipart POST to the presign endpoint. - Event code gate: The
readCode()function (line 424) reads from the#g-codeinput field. If a code is present, it's included in the upload request and the Lambda grants instant publish. Without a code, uploads go to a review queue. The UI copy explains: "The code keeps strangers from posting to your charter page." - Instagram grid: Lines 452–459 render an
#ig-gridcontainer populated from thed.instagramarray. This array is returned by the Lambda'sGET /api/g/{event_id}/photosendpoint and contains posts from the same calendar day matching the hashtags.
The Lambda Backend: shipcaptaincrew
File location: /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py
The Lambda function is the orchestration heart. It's deployed as shipcaptaincrew and aliased to both staging and production CloudFront distributions. Key handlers:
- POST /upload/presign: Returns a pre-signed S3 URL for the guest page to upload directly to S3. The request includes the event code (or empty string). The Lambda validates the code against DynamoDB and returns a presigned URL scoped to
s3://queenofsandiego.com/uploads/{event_id}/{filename}. If the code is invalid or missing, the bucket policy restricts the file to a quarantine prefix pending moderation. - GET /api/g/{event_id}/photos: Returns the combined gallery: published photos + videos from S3 thumbnails, plus the day's Instagram posts. The function queries DynamoDB for the event metadata (including IG hashtag search params), calls the Instagram Graph API (or a cached IG fetcher), and merges the results into the
d.instagramandd.photosarrays. - POST /upload/submit (internal moderation): Called by the Lambda's own workflow after an upload is detected in the quarantine S3 prefix. The Lambda extracts metadata, generates a thumbnail, and either publishes immediately (if code matches) or sends a moderation email to the charter host with an approval link.
Why Lambda + direct S3 presigning? Pre-signed URLs bypass Lambda for the actual file upload, reducing Lambda memory/time pressure and avoiding multipart form parsing in Python. The guest page POSTs to S3 directly with the presigned URL, and S3 events trigger the Lambda's validation logic asynchronously.
S3 and CloudFront Architecture
Buckets involved:
queenofsandiego.com— main website + guest pages + published photos- Staging bucket (filtered during session) — isolated copy for testing
Prefixes:
/g/{event_id}/index.html— guest page (deployed once per event)/uploads/{event_id}/— raw uploads from guests (some quarantined, some published)/thumbs/{event_id}/— generated thumbnails (built by a backfill script after moderation)
CloudFront invalidation: After deploying the guest page or updating the Lambda, we invalidate staging/prod CloudFront distributions with /* patterns. This ensures fresh gallery data and corrects any stale IG feeds cached by older Lambda versions.
CORS configuration: During the session, we updated S3 CORS rules to allow preflight requests from queenofsandiego.com origins (prod and staging). This was necessary because the guest page (served from CloudFront) makes direct PUT requests to S3 presigned URLs.
Photo Thumbnail Pipeline
File location: /tmp/backfill-thumbs.py (local development script)
After uploads are approved and published, we generate thumbnails using Pillow. The backfill script:
- Lists all published photos in
s3://queenofsandiego.com/uploads/{event_id}/ - Downloads each image and resizes it to a standard thumbnail dimension
- Uploads the thumbnail to
s3://queenofsandiego.com/thumbs/{event_id}/{filename} - Updates DynamoDB metadata to point to the thumbnail URL
The Lambda's photo handler then returns thumbnail URLs for the gallery instead of full-resolution images, reducing bandwidth and improving page load time.
Instagram Integration
The GET /api/g/{event_id}/photos Lambda endpoint queries the Instagram Graph API for posts tagged with #jada or #queenofsandiego from the same calendar day as the event. The IG token is stored in Lambda environment variables (not in code) and