Building the Sailor Board: Shipping Photo Upload, Moderation, and Instagram Integration for Guest Event Pages
This post walks through a full-stack build session that took Queen of San Diego's guest event pages from static landing pages to dynamic photo galleries with upload, spam prevention, moderation workflows, and Instagram feed integration. The work involved Lambda function expansion, S3 CORS policy updates, CloudFront cache invalidation, and a new booking widget embedded across the site.
What Was Done
- Photo upload pipeline: Added client-side file handling to guest pages with support for JPEG, PNG, WebP, HEIC, HEIF (image) and MP4, MOV, AVI, WebM (video), capped at 24 files per submission.
- Spam gate: Implemented an event-specific code verification system that allows instant publish for code-holders, queues unverified uploads for moderation.
- Same-day Instagram integration: Wired Lambda to fetch Instagram posts matching
#jadaor#queenofsandiegohashtags from the event date and render them alongside user uploads. - Booking modal fix: Modified the homepage and guest pages to open the booking/payment flow inline via modal instead of redirecting to a separate page.
- Widget extraction: Extracted the booking modal into a reusable JavaScript widget (
booking-widget.js) for embed across multiple pages.
Technical Details: The Upload Flow
The guest event page lives at /g/{event_id} on the main site—for example, queenofsandiego.com/g/2026-05-24-keely-afternoon. When a guest lands post-event (determined by a FLIP_UTC timestamp in the page), the UI shifts from "book a sail" mode to "upload photos" mode.
Client-side file handling happens in the embedded inline <script> block:
// Line 362: File input element
<input type="file" id="file-input" multiple accept=".jpg,.jpeg,.png,.webp,.heic,.heif,.mp4,.mov,.avi,.webm" />
// Line 515: Upload handler with 24-file cap
Array.from(files).slice(0, 24).forEach(uploadFile)
Each file is posted to POST /api/g/{event_id}/presigned-upload to get a temporary S3 signed URL. The flow:
- Client reads file metadata (name, size, type)
- Calls presign endpoint with file details
- Lambda returns AWS SigV4-signed PUT URL (15-minute expiry)
- Client uploads directly to S3 bucket
qos-guest-uploads - Lambda receives S3 event notification, validates, stores metadata
The spam gate is a simple but effective pattern: if the guest provides the event code (stored in #g-code` input, line 354), their upload goes straight to the photo grid. Without it, the upload lands in a DynamoDB moderation table for an admin review queue. The Lambda logs this decision at line 424 (readCode()) and the moderation email copy (line 2066) includes a link back to the page: "View sailor board" — which is just the same per-event gallery page.
Why this matters: A single event page becomes both a public gallery and a behind-the-scenes moderation checkpoint. No separate admin UI; no new infrastructure. The existing `/g/` page serves both audiences.
Instagram Integration: Server-Side Fetch, Client-Side Render
The page includes a second grid: <div id="ig-grid"> (lines 452–459). That gets populated from the Lambda response, which runs GET /api/g/{event_id}/photos. The Lambda handler (in tools/shipcaptaincrew/lambda_function.py) includes Instagram search logic that:
- Takes the event date from the URL slug (e.g.,
2026-05-24) - Queries Instagram's API for posts with hashtags
#jadaor#queenofsandiegofrom that date - Returns results in a
d.instagramarray - Client renders them as a gallery grid alongside uploaded photos
This is server-side fetch, client-side render—Lambda does the rate-limited Instagram work once, caches the response, and the page just displays what it gets. No client-side Instagram SDK bloat.
Infrastructure Changes
S3 CORS policy update: Photo uploads from the browser require CORS headers. We updated the bucket policy for qos-guest-uploads to allow preflight requests from queenofsandiego.com origins:
Bucket: qos-guest-uploads
Action: Update CORS configuration
Allow origins: ["https://queenofsandiego.com", "https://*.queenofsandiego.com"]
Allow methods: ["GET", "PUT", "POST", "OPTIONS"]
Allow headers: ["*"]
Expose headers: ["ETag"]
Max age: 3600
Lambda deployment: Modified lambda_function.py multiple times to add the photo-handler logic, Instagram fetcher, and moderation queue. Deployed via zip build to the existing shipcaptaincrew function. Each deployment included a syntax validation and smoke test of the presigned-upload and photo-fetch endpoints.
CloudFront invalidation: After deploying the new sailor-board/index.html page and updated homepage to staging, we invalidated the CloudFront distribution cache with:
aws cloudfront create-invalidation \
--distribution-id [STAGING_DIST_ID] \
--paths "/*"
This ensures browsers fetch the latest HTML immediately, not stale cache.
The Booking Widget Refactor
The original homepage had an inline booking modal script. To reuse it across the guest pages, we extracted it into booking-widget.js. The key insight: the modal markup, CSS, and JavaScript were tightly coupled in the HTML. Extraction meant:
- Pull the modal
<div>structure into a template string in the JS file - Move all CSS rules into a scoped
<style>block injected at runtime - Expose a single function:
openBookingModal() - Update all nav links and CTA buttons to call that function instead of linking away
This was critical for user experience: the old flow would redirect to a separate checkout page, losing context. Now, clicking "Book a Sail" opens the Stripe checkout flow in a modal overlay—guests stay on the event page, see the photo gallery, and complete payment without navigation.
Key Decisions
Why presigned URLs instead of direct Lambda upload? Browsers can't safely hold AWS credentials. Presigned URLs let Lambda hand out temporary, file-specific upload tokens. Client uploads directly to S3, bypassing Lambda (saves compute, faster for large files), and Lambda still gets notified via S3