Building the Sailor Board: Photo Upload Pipeline, Event-Gated Publishing, and Same-Day Instagram Integration for Charter Guest Pages
What Was Done
We completed implementation of a multi-feature photo publishing system for event guest pages, enabling charter guests to upload photos/videos with optional event-code gating, automatic moderation workflows, and real-time Instagram hashtag aggregation. The system went live for the Keely afternoon charter (2026-05-24) and is now in production.
The "Sailor Board" is not a separate feature—it's the photo gallery on each per-event guest page at /g/{event_id}. The marketing copy ("Upload your photos and we'll add the best ones to our sailor board") refers to that same page's #photo-grid where uploads appear after moderation or immediate publish (if event code provided).
Technical Architecture
File Storage & Upload Flow
Guest uploads hit the Lambda presigner endpoint, which generates S3 signed POST URLs. Files land in the event-specific prefix:
s3://queenofsandiego-photos/events/{event_id}/uploads/{filename}
The Lambda function (/Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py) handles:
- Presigning:
POST /api/g/{event_id}/presign— validates event existence, returns signed S3 POST policy - File acceptance: JPEG, PNG, WebP, HEIC, HEIF, MP4, MOV, AVI, WebM (MIME type validated server-side)
- Thumbnail generation: On upload completion, Lambda triggers thumbnail backfill via
/tmp/backfill-thumbs.py, generating 320px-wide WebP thumbnails - Metadata storage: Each upload creates a DynamoDB record in the photos table with
event_idpartition key,uploaded_atsort key
Event Code Gating & Moderation
The guest page (/Users/cb/Documents/repos/sites/queenofsandiego.com/g/2026-05-24-keely-afternoon/index.html) includes a modal with two inputs:
#g-code— optional event code (e.g., "JADA2024")#file-input— file picker, accepts up to 24 files at once viaArray.from(files).slice(0, 24).forEach(uploadFile)
Upload logic in booking-widget.js (lines 514–550):
// Pseudocode flow
if (eventCode provided) {
// Direct publish: skip review queue
POST to /api/g/{event_id}/publish with code
Set DDB record: published=true, moderation_status='approved'
Send moderation email: "Photo approved and live"
} else {
// Moderation queue: requires review
POST to /api/g/{event_id}/upload (no code)
Set DDB record: published=false, moderation_status='pending'
Send moderation email: "Photo awaiting review"
UI displays: "Thanks! Our crew will review..."
}
The code prevents spam by:
- Acting as a shared secret between event organizer and guests (included in charter confirmation email)
- Enabling instant publish only with valid code; without it, all uploads enter human review queue
- Stored as plaintext in DDB (not hashed) but only visible to organizer; guests never see other guests' codes
Instagram Hashtag Integration
The guest page renders an #ig-grid (lines 452–459 in sailor-board/index.html) populated by the server. On page load, JavaScript calls:
GET /api/g/{event_id}/photos
This Lambda handler returns a JSON payload:
{
"photos": [...],
"instagram": [
{ url, caption, timestamp, source: "instagram" },
...
]
}
The Instagram data is populated by a separate Lambda handler that:
- Accepts webhook events from the Instagram Business Account API (or scheduled poll via CloudWatch Events)
- Searches for posts created on the event date with hashtags
#jadaor#queenofsandiego - Stores matching posts in DDB under the event's
instagramattribute - Returns them to the guest page for rendering in the same gallery grid
This is wired at the Lambda function handler level but the exact endpoint and polling mechanism was already live before this session (not visible in today's edits).
Infrastructure & Deployment
S3 & CORS
Updated S3 CORS policy on queenofsandiego-photos bucket to allow presigned POST from production origin:
{
"AllowedOrigins": ["https://queenofsandiego.com"],
"AllowedMethods": ["GET", "PUT", "POST"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
This was critical for CORS preflight to pass on guest page uploads.
Lambda Deployment
The shipcaptaincrew Lambda function handles all API endpoints. Deployment steps:
# 1. Snapshot prod code locally for safe merge
aws lambda get-function \
--function-name shipcaptaincrew \
--query 'Code.Location' | xargs curl -o prod.zip
# 2. Extract and diff to identify safe merge points
unzip -q prod.zip -d prod_extracted/
diff -u prod_extracted/lambda_function.py local/lambda_function.py
# 3. Build deployment package with dependencies
pip install -r requirements.txt -t package/
cp lambda_function.py package/
cd package && zip -r9 ../deploy.zip . && cd ..
# 4. Deploy
aws lambda update-function-code \
--function-name shipcaptaincrew \
--zip-file fileb://deploy.zip
Lambda runtime: Python 3.11. Size after bundling photo handler + IG integration: ~2.8 MB (including py_vapid for push notifications).
CloudFront & Guest Page Routing
Guest pages are served via CloudFront distribution (staging alias: d-{staging-dist-id}.cloudfront.net) pointing to the S3 origin. Route pattern for guest pages:
/g/{event_id} → s3://queenofsandiego.com/g/{event_id}/index.html
After deploying the sailor-board page to staging, invalidation command:
aws cloudfront create-invalidation \
--distribution-id {STAGING_DIST_ID} \
--paths '/g/*' '/index.html'