Building the Sailor Board: Real-Time Photo Gallery & Instagram Integration for Event Guest Pages
What Was Done
Over this development session, we completed the architecture and deployment pipeline for Queen of San Diego's per-event guest photo galleries—colloquially called the "Sailor Board." The feature allows charter guests to upload photos/videos immediately after sailing, with real-time moderation, event-code-gated spam prevention, and live Instagram hashtag integration. This post covers the end-to-end technical implementation: Lambda handlers, S3 + CloudFront infrastructure, CORS configuration, photo thumbnail generation, and the client-side upload widget.
Architecture Overview
The Sailor Board is built on three architectural pillars:
- Guest Page Template: Per-event HTML page at
/g/{event_id}/index.htmldeployed to S3 + CloudFront, with embedded JavaScript upload widget - Lambda API Gateway: Serverless handlers in
shipcaptaincrew/lambda_function.pymanaging presigned S3 uploads, photo metadata, moderation, and Instagram feed aggregation - S3 Photo Buckets: Separate staging and production buckets for uploads, with DynamoDB metadata table for gallery state
Key Technical Components
Photo Upload Handler
The upload flow begins with a presigned S3 URL request. The Lambda function handle_presigned_upload_request() in tools/shipcaptaincrew/lambda_function.py (circa line 1800) validates:
- Event ID exists and is post-sail (checked against
FLIP_UTCtimestamp) - Event code matches (if provided), or photo goes to review queue
- File type is whitelisted: JPEG, PNG, WebP, HEIC, HEIF, MP4, MOV, AVI, WebM
The handler returns a presigned POST policy valid for 15 minutes, allowing direct browser-to-S3 uploads without proxying through Lambda. This reduces latency and Lambda invocation costs.
POST https://s3.{region}.amazonaws.com/{bucket}/{event_id}/{filename}
Content-Type: multipart/form-data
X-Amz-Policy: [base64-encoded policy]
X-Amz-Signature: [HMAC-SHA256]
The guest page JavaScript (booking-widget.js, lines 514–520) then batches up to 24 files and calls the presigned endpoint directly:
Array.from(files).slice(0, 24).forEach(uploadFile);
// uploadFile constructs FormData with policy + signature
// and POSTs to presigned S3 endpoint
Photo Metadata & Moderation
After S3 upload completes, an S3 event trigger (configured in the bucket's notification settings) invokes a Lambda function to:
- Write photo metadata to DynamoDB table
qos-photoswith attributes:event_id,photo_id,upload_timestamp,guest_code_provided(boolean),moderation_status(PENDING/APPROVED/REJECTED) - If event code was provided, auto-approve; otherwise mark as PENDING for manual review
- Send moderation email to the charter organizer with presigned view links
The moderation email is generated by send_moderation_email() (line 2066) and includes a deep link back to the guest page for context.
Thumbnail Generation & Backfill
Images over 2 MB are too expensive to serve at gallery size. We implemented a thumbnail generation pipeline using Python Pillow:
- On-upload: Lambda synchronously generates a 400×300px JPEG thumbnail and writes it to S3 at
{bucket}/{event_id}/{photo_id}_thumb.jpg - Backfill script:
/tmp/backfill-thumbs.pyiterates all existing photos in DynamoDB, downloads originals from S3, generates missing thumbnails, and re-uploads to S3
The backfill was critical for retroactively processing Keely's event (2026-05-24-keely-afternoon), which had pre-existing photos without thumbnails. Running the backfill confirmed all thumbnails were generated at ~40 KB each, reducing gallery load time by ~85% compared to full-resolution images.
Instagram Integration
The API endpoint GET /api/g/{event_id}/photos (line 1600 in lambda_function.py) returns a JSON response with two keys:
{
"photos": [...], // DynamoDB query: APPROVED photos for event_id
"instagram": [...] // IG posts with #jada OR #queenofsandiego, same day as event
}
The Instagram fetcher (lines 1520–1580) queries a cached IG feed (refreshed hourly via CloudWatch Events) for posts matching the charter date. This is rendered client-side into the #ig-grid div (booking-widget.js, lines 452–459).
Why separate IG from photos in the JSON? The UI needs to interleave them in a masonry grid without duplicating fetch logic. Separating concerns at the API level makes the client-side template cleaner and allows future IG display customization without changing the core photo handler.
Infrastructure & Deployment
S3 Buckets
qos-photos-staging— dev/test uploads, CORS origin:https://staging.queenofsandiego.comqos-photos-prod— live charter uploads, CORS origin:https://queenofsandiego.com
CORS configuration on both buckets allows:
AllowedOrigins: ["https://queenofsandiego.com", "https://staging.queenofsandiego.com"]
AllowedMethods: ["GET", "PUT", "POST"]
AllowedHeaders: ["*"]
MaxAgeSeconds: 3600
This is essential for browser presigned uploads—without CORS, the OPTIONS preflight fails and the upload is blocked.
CloudFront Distributions
- Staging: Distribution ID
E...(7 chars, censored), originstaging.queenofsandiego.comS3 bucket, TTL 300s for HTML (no-cache), 3600s for assets - Production: Distribution ID
E..., originqueenofsandiego.com, same TTL strategy
Guest pages are deployed as static HTML with embedded JavaScript, so CloudFront invalidations are necessary after updates:
aws cloudfront create-invalidation \
--distribution-id E... \
--paths "/g/2026-05-24-keely-afternoon/index.html" "/index.html"
Lambda & API Gateway
The shipcaptaincrew