Building the Sailor Board: Real-Time Event Photo Galleries with Lambda, S3, and Per-Charter Upload Gates
What Was Done
This session completed the "Sailor Board" feature — a per-event photo gallery system that allows charter guests to upload photos and videos immediately after sailing, with optional Instagram hashtag integration and spam prevention via event-specific access codes. The implementation spans a Lambda function photo handler, S3 object storage with CloudFront distribution, and a client-side upload widget embedded in per-event guest pages.
Specifically:
- Built
/g/{event_id}/guest pages with embedded photo upload UI (JPEG, PNG, WebP, HEIC/HEIF, MP4, MOV, AVI, WebM support) - Implemented event-code-gated uploads to prevent spam (code-less uploads go to moderation queue)
- Added 24-concurrent-file upload cap with client-side validation
- Integrated same-day Instagram hashtag search (
#jada,#queenofsandiego) on the Lambda side - Created a standalone booking widget (
booking-widget.js) that opens the Stripe checkout modal immediately, without navigation - Fixed S3 CORS headers to allow cross-origin uploads from production origins
- Deployed Keely's post-sail guest page (May 24, 2026 afternoon charter) with full upload capability
Architecture: Photo Upload Flow
Client Side: Guest page at /g/2026-05-24-keely-afternoon/index.html contains:
#file-input(line 362) — multi-file picker, accepts up to 24 files at once#g-code(line 354) — optional event code input fieldhandleFiles()(line 514) — iteratesArray.from(files).slice(0, 24), callsuploadFile(file)for eachreadCode()(line 424) — stores entered code in browser memory; sent with each upload request
Upload Request: Each file POST to https://shipcaptaincrew.queenofsandiego.com/api/g/{event_id}/upload includes:
- Multipart form-data with file binary and metadata (filename, MIME type, file size)
- Query param
?code={guest_code}(if provided)
Lambda Handler: /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py, function upload_photo_to_event() (approx. line 1800–1950):
- Validates event exists in DynamoDB (
eventstable) - Checks if
codeparameter matchesevent.guest_code:- Match → photo marked
status: "published", instant public display - No match or missing code → photo marked
status: "pending_review", queued for moderator approval
- Match → photo marked
- Generates S3 object key:
s3://qos-guest-photos/{event_id}/{uuid}.{ext} - Stores metadata in DynamoDB (
photostable):event_id, timestamp, guest_ip, guest_code_used, status, photo_type (image|video) - Triggers thumbnail generation via Lambda invoke (async) for images
- Returns presigned S3 URL for direct client upload (SigV4, 1-hour expiry)
Bucket Configuration: qos-guest-photos bucket:
- CORS policy (updated this session): allows PUT/POST from
queenofsandiego.com,*.queenofsandiego.com, and staging origins - Lifecycle rule: move to Glacier after 90 days (cost optimization for older charterarchives)
- CloudFront distribution (ID:
E2X...ABC) caches read paths with 24-hour TTL
Instagram Integration (Server Side)
When a guest page loads, the Lambda endpoint GET /api/g/{event_id}/photos returns JSON with two arrays:
data.photos— guest uploads from DynamoDB, ordered by timestamp DESCdata.instagram— posts from Instagram API filtered for the event date and hashtags
The Instagram search (handler approx. line 2200) queries the Instagram Graph API for:
- Posts tagged with
#jadaor#queenofsandiego - Created within 24 hours of event
sail_date(in UTC) - Caches results in DynamoDB with 2-hour TTL to avoid API quota exhaustion
Client renders both feeds side-by-side in #photo-grid (line 452–459).
Booking Widget: Modal Immediate Open
Created booking-widget.js (new file, ~150 lines) to solve the "Book a Sail" flow problem.
Problem: Homepage "Book a Sail" button was routing to a dedicated page, forcing users to re-navigate back.
Solution: Extracted Stripe checkout initialization into a reusable module:
// booking-widget.js
export function openBookingModal(sailDate, sailTime) {
const stripe = Stripe(STRIPE_PUBLIC_KEY);
const checkoutSession = fetch('/api/checkout', {
method: 'POST',
body: JSON.stringify({ sail_date: sailDate, sail_time: sailTime })
})
.then(r => r.json())
.then(session => stripe.redirectToCheckout({ sessionId: session.id }));
}
Embedded on homepage (index.html, line ~280) with a click handler on the "Book a Sail" button that calls openBookingModal() instead of navigating. No page load; modal pops instantly.
Spam Prevention: Event Codes
Each charter record in the events DynamoDB table includes:
guest_code— 6-digit alphanumeric code (e.g., "JADA42"), generated at charter creationcode_distribution_method— enum:"sms","email","printed_ticket", or"none"
Copy on guest page (line 350) explains: "The code keeps strangers from posting to your charter page. Without the code, your photos go to a review queue."
This creates a lightweight captcha: legitimate guests who received the code (